From 4b05df45ada7e872525bb485387bbebbd0ce0869 Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 12 Mar 2021 14:34:50 -0800 Subject: [PATCH] Add SageMaker Domain, UserProfile, App and AppImageConfig resources (#7) - add AWS::SageMaker::Domain CloudFormation resource type - add AWS::SageMaker::UserProfile CloudFormation resource type - add AWS::SageMaker::App CloudFormation resource type - add AWS::SageMaker::AppImageConfig CloudFormation resource type - update repo README to reflect new resource types --- README.md | 4 + aws-sagemaker-app/.rpdk-config | 17 + aws-sagemaker-app/README.md | 12 + aws-sagemaker-app/aws-sagemaker-app.json | 184 +++++++++ aws-sagemaker-app/docs/README.md | 128 ++++++ aws-sagemaker-app/docs/resourcespec.md | 70 ++++ aws-sagemaker-app/docs/tag.md | 48 +++ aws-sagemaker-app/lombok.config | 1 + aws-sagemaker-app/pom.xml | 210 ++++++++++ aws-sagemaker-app/resource-role.yaml | 34 ++ .../amazon/sagemaker/app/BaseHandlerStd.java | 36 ++ .../amazon/sagemaker/app/CallbackContext.java | 10 + .../amazon/sagemaker/app/ClientBuilder.java | 12 + .../amazon/sagemaker/app/Configuration.java | 8 + .../amazon/sagemaker/app/CreateHandler.java | 133 +++++++ .../amazon/sagemaker/app/DeleteHandler.java | 132 +++++++ .../amazon/sagemaker/app/ListHandler.java | 74 ++++ .../amazon/sagemaker/app/ReadHandler.java | 80 ++++ .../amazon/sagemaker/app/Translator.java | 81 ++++ .../sagemaker/app/TranslatorForRequest.java | 89 +++++ .../sagemaker/app/TranslatorForResponse.java | 62 +++ .../sagemaker/app/AbstractTestBase.java | 57 +++ .../sagemaker/app/CreateHandlerTest.java | 337 ++++++++++++++++ .../sagemaker/app/DeleteHandlerTest.java | 282 +++++++++++++ .../amazon/sagemaker/app/ListHandlerTest.java | 177 +++++++++ .../amazon/sagemaker/app/ReadHandlerTest.java | 183 +++++++++ .../amazon/sagemaker/app/TranslatorTest.java | 167 ++++++++ aws-sagemaker-app/template.yml | 24 ++ aws-sagemaker-appimageconfig/.rpdk-config | 17 + aws-sagemaker-appimageconfig/README.md | 12 + .../aws-sagemaker-appimageconfig.json | 172 ++++++++ aws-sagemaker-appimageconfig/docs/README.md | 86 ++++ .../docs/filesystemconfig.md | 64 +++ .../docs/kernelgatewayimageconfig.md | 47 +++ .../docs/kernelspec.md | 52 +++ aws-sagemaker-appimageconfig/docs/tag.md | 48 +++ aws-sagemaker-appimageconfig/lombok.config | 1 + aws-sagemaker-appimageconfig/pom.xml | 210 ++++++++++ .../resource-role.yaml | 35 ++ .../appimageconfig/BaseHandlerStd.java | 36 ++ .../appimageconfig/CallbackContext.java | 10 + .../appimageconfig/ClientBuilder.java | 9 + .../appimageconfig/Configuration.java | 8 + .../appimageconfig/CreateHandler.java | 80 ++++ .../appimageconfig/DeleteHandler.java | 65 +++ .../sagemaker/appimageconfig/ListHandler.java | 74 ++++ .../sagemaker/appimageconfig/ReadHandler.java | 69 ++++ .../sagemaker/appimageconfig/Translator.java | 81 ++++ .../appimageconfig/TranslatorForRequest.java | 106 +++++ .../appimageconfig/TranslatorForResponse.java | 62 +++ .../appimageconfig/UpdateHandler.java | 72 ++++ .../appimageconfig/AbstractTestBase.java | 51 +++ .../appimageconfig/CreateHandlerTest.java | 207 ++++++++++ .../appimageconfig/DeleteHandlerTest.java | 110 ++++++ .../appimageconfig/ListHandlerTest.java | 140 +++++++ .../appimageconfig/ReadHandlerTest.java | 183 +++++++++ .../appimageconfig/TranslatorTest.java | 167 ++++++++ .../appimageconfig/UpdateHandlerTest.java | 131 ++++++ aws-sagemaker-appimageconfig/template.yml | 24 ++ aws-sagemaker-domain/.rpdk-config | 17 + aws-sagemaker-domain/README.md | 12 + .../aws-sagemaker-domain.json | 367 +++++++++++++++++ aws-sagemaker-domain/docs/README.md | 173 ++++++++ aws-sagemaker-domain/docs/customimage.md | 66 ++++ .../docs/jupyterserverappsettings.md | 32 ++ .../docs/kernelgatewayappsettings.md | 45 +++ aws-sagemaker-domain/docs/resourcespec.md | 66 ++++ aws-sagemaker-domain/docs/sharingsettings.md | 68 ++++ aws-sagemaker-domain/docs/tag.md | 48 +++ aws-sagemaker-domain/docs/usersettings.md | 89 +++++ aws-sagemaker-domain/lombok.config | 1 + aws-sagemaker-domain/pom.xml | 210 ++++++++++ aws-sagemaker-domain/resource-role.yaml | 44 +++ .../sagemaker/domain/BaseHandlerStd.java | 36 ++ .../sagemaker/domain/CallbackContext.java | 10 + .../sagemaker/domain/ClientBuilder.java | 12 + .../sagemaker/domain/Configuration.java | 8 + .../sagemaker/domain/CreateHandler.java | 179 +++++++++ .../sagemaker/domain/DeleteHandler.java | 110 ++++++ .../amazon/sagemaker/domain/ListHandler.java | 74 ++++ .../amazon/sagemaker/domain/ReadHandler.java | 72 ++++ .../amazon/sagemaker/domain/Translator.java | 81 ++++ .../domain/TranslatorForRequest.java | 165 ++++++++ .../domain/TranslatorForResponse.java | 128 ++++++ .../sagemaker/domain/UpdateHandler.java | 120 ++++++ .../sagemaker/domain/AbstractTestBase.java | 70 ++++ .../sagemaker/domain/CreateHandlerTest.java | 372 ++++++++++++++++++ .../sagemaker/domain/DeleteHandlerTest.java | 191 +++++++++ .../sagemaker/domain/ListHandlerTest.java | 139 +++++++ .../sagemaker/domain/ReadHandlerTest.java | 239 +++++++++++ .../sagemaker/domain/TranslatorTest.java | 167 ++++++++ .../sagemaker/domain/UpdateHandlerTest.java | 216 ++++++++++ aws-sagemaker-domain/template.yml | 24 ++ aws-sagemaker-userprofile/.rpdk-config | 17 + aws-sagemaker-userprofile/README.md | 12 + .../aws-sagemaker-userprofile.json | 312 +++++++++++++++ aws-sagemaker-userprofile/docs/README.md | 126 ++++++ aws-sagemaker-userprofile/docs/customimage.md | 66 ++++ .../docs/jupyterserverappsettings.md | 32 ++ .../docs/kernelgatewayappsettings.md | 45 +++ .../docs/resourcespec.md | 66 ++++ .../docs/sharingsettings.md | 68 ++++ aws-sagemaker-userprofile/docs/tag.md | 48 +++ .../docs/usersettings.md | 89 +++++ aws-sagemaker-userprofile/lombok.config | 1 + aws-sagemaker-userprofile/pom.xml | 210 ++++++++++ aws-sagemaker-userprofile/resource-role.yaml | 38 ++ .../sagemaker/userprofile/BaseHandlerStd.java | 36 ++ .../userprofile/CallbackContext.java | 10 + .../sagemaker/userprofile/ClientBuilder.java | 12 + .../sagemaker/userprofile/Configuration.java | 8 + .../sagemaker/userprofile/CreateHandler.java | 137 +++++++ .../sagemaker/userprofile/DeleteHandler.java | 107 +++++ .../sagemaker/userprofile/ListHandler.java | 74 ++++ .../sagemaker/userprofile/ReadHandler.java | 69 ++++ .../sagemaker/userprofile/Translator.java | 81 ++++ .../userprofile/TranslatorForRequest.java | 167 ++++++++ .../userprofile/TranslatorForResponse.java | 123 ++++++ .../sagemaker/userprofile/UpdateHandler.java | 107 +++++ .../userprofile/AbstractTestBase.java | 67 ++++ .../userprofile/CreateHandlerTest.java | 297 ++++++++++++++ .../userprofile/DeleteHandlerTest.java | 195 +++++++++ .../userprofile/ListHandlerTest.java | 141 +++++++ .../userprofile/ReadHandlerTest.java | 235 +++++++++++ .../sagemaker/userprofile/TranslatorTest.java | 167 ++++++++ .../userprofile/UpdateHandlerTest.java | 233 +++++++++++ aws-sagemaker-userprofile/template.yml | 24 ++ 127 files changed, 12152 insertions(+) create mode 100644 aws-sagemaker-app/.rpdk-config create mode 100644 aws-sagemaker-app/README.md create mode 100644 aws-sagemaker-app/aws-sagemaker-app.json create mode 100644 aws-sagemaker-app/docs/README.md create mode 100644 aws-sagemaker-app/docs/resourcespec.md create mode 100644 aws-sagemaker-app/docs/tag.md create mode 100644 aws-sagemaker-app/lombok.config create mode 100644 aws-sagemaker-app/pom.xml create mode 100644 aws-sagemaker-app/resource-role.yaml create mode 100644 aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/BaseHandlerStd.java create mode 100644 aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/CallbackContext.java create mode 100644 aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/ClientBuilder.java create mode 100644 aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/Configuration.java create mode 100644 aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/CreateHandler.java create mode 100644 aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/DeleteHandler.java create mode 100644 aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/ListHandler.java create mode 100644 aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/ReadHandler.java create mode 100644 aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/Translator.java create mode 100644 aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/TranslatorForRequest.java create mode 100644 aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/TranslatorForResponse.java create mode 100644 aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/AbstractTestBase.java create mode 100644 aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/CreateHandlerTest.java create mode 100644 aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/DeleteHandlerTest.java create mode 100644 aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/ListHandlerTest.java create mode 100644 aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/ReadHandlerTest.java create mode 100644 aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/TranslatorTest.java create mode 100644 aws-sagemaker-app/template.yml create mode 100644 aws-sagemaker-appimageconfig/.rpdk-config create mode 100644 aws-sagemaker-appimageconfig/README.md create mode 100644 aws-sagemaker-appimageconfig/aws-sagemaker-appimageconfig.json create mode 100644 aws-sagemaker-appimageconfig/docs/README.md create mode 100644 aws-sagemaker-appimageconfig/docs/filesystemconfig.md create mode 100644 aws-sagemaker-appimageconfig/docs/kernelgatewayimageconfig.md create mode 100644 aws-sagemaker-appimageconfig/docs/kernelspec.md create mode 100644 aws-sagemaker-appimageconfig/docs/tag.md create mode 100644 aws-sagemaker-appimageconfig/lombok.config create mode 100644 aws-sagemaker-appimageconfig/pom.xml create mode 100644 aws-sagemaker-appimageconfig/resource-role.yaml create mode 100644 aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/BaseHandlerStd.java create mode 100644 aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/CallbackContext.java create mode 100644 aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/ClientBuilder.java create mode 100644 aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/Configuration.java create mode 100644 aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/CreateHandler.java create mode 100644 aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/DeleteHandler.java create mode 100644 aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/ListHandler.java create mode 100644 aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/ReadHandler.java create mode 100644 aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/Translator.java create mode 100644 aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/TranslatorForRequest.java create mode 100644 aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/TranslatorForResponse.java create mode 100644 aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/UpdateHandler.java create mode 100644 aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/AbstractTestBase.java create mode 100644 aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/CreateHandlerTest.java create mode 100644 aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/DeleteHandlerTest.java create mode 100644 aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/ListHandlerTest.java create mode 100644 aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/ReadHandlerTest.java create mode 100644 aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/TranslatorTest.java create mode 100644 aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/UpdateHandlerTest.java create mode 100644 aws-sagemaker-appimageconfig/template.yml create mode 100644 aws-sagemaker-domain/.rpdk-config create mode 100644 aws-sagemaker-domain/README.md create mode 100644 aws-sagemaker-domain/aws-sagemaker-domain.json create mode 100644 aws-sagemaker-domain/docs/README.md create mode 100644 aws-sagemaker-domain/docs/customimage.md create mode 100644 aws-sagemaker-domain/docs/jupyterserverappsettings.md create mode 100644 aws-sagemaker-domain/docs/kernelgatewayappsettings.md create mode 100644 aws-sagemaker-domain/docs/resourcespec.md create mode 100644 aws-sagemaker-domain/docs/sharingsettings.md create mode 100644 aws-sagemaker-domain/docs/tag.md create mode 100644 aws-sagemaker-domain/docs/usersettings.md create mode 100644 aws-sagemaker-domain/lombok.config create mode 100644 aws-sagemaker-domain/pom.xml create mode 100644 aws-sagemaker-domain/resource-role.yaml create mode 100644 aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/BaseHandlerStd.java create mode 100644 aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/CallbackContext.java create mode 100644 aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/ClientBuilder.java create mode 100644 aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/Configuration.java create mode 100644 aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/CreateHandler.java create mode 100644 aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/DeleteHandler.java create mode 100644 aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/ListHandler.java create mode 100644 aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/ReadHandler.java create mode 100644 aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/Translator.java create mode 100644 aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/TranslatorForRequest.java create mode 100644 aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/TranslatorForResponse.java create mode 100644 aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/UpdateHandler.java create mode 100644 aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/AbstractTestBase.java create mode 100644 aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/CreateHandlerTest.java create mode 100644 aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/DeleteHandlerTest.java create mode 100644 aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/ListHandlerTest.java create mode 100644 aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/ReadHandlerTest.java create mode 100644 aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/TranslatorTest.java create mode 100644 aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/UpdateHandlerTest.java create mode 100644 aws-sagemaker-domain/template.yml create mode 100644 aws-sagemaker-userprofile/.rpdk-config create mode 100644 aws-sagemaker-userprofile/README.md create mode 100644 aws-sagemaker-userprofile/aws-sagemaker-userprofile.json create mode 100644 aws-sagemaker-userprofile/docs/README.md create mode 100644 aws-sagemaker-userprofile/docs/customimage.md create mode 100644 aws-sagemaker-userprofile/docs/jupyterserverappsettings.md create mode 100644 aws-sagemaker-userprofile/docs/kernelgatewayappsettings.md create mode 100644 aws-sagemaker-userprofile/docs/resourcespec.md create mode 100644 aws-sagemaker-userprofile/docs/sharingsettings.md create mode 100644 aws-sagemaker-userprofile/docs/tag.md create mode 100644 aws-sagemaker-userprofile/docs/usersettings.md create mode 100644 aws-sagemaker-userprofile/lombok.config create mode 100644 aws-sagemaker-userprofile/pom.xml create mode 100644 aws-sagemaker-userprofile/resource-role.yaml create mode 100644 aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/BaseHandlerStd.java create mode 100644 aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/CallbackContext.java create mode 100644 aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/ClientBuilder.java create mode 100644 aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/Configuration.java create mode 100644 aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/CreateHandler.java create mode 100644 aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/DeleteHandler.java create mode 100644 aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/ListHandler.java create mode 100644 aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/ReadHandler.java create mode 100644 aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/Translator.java create mode 100644 aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/TranslatorForRequest.java create mode 100644 aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/TranslatorForResponse.java create mode 100644 aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/UpdateHandler.java create mode 100644 aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/AbstractTestBase.java create mode 100644 aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/CreateHandlerTest.java create mode 100644 aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/DeleteHandlerTest.java create mode 100644 aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/ListHandlerTest.java create mode 100644 aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/ReadHandlerTest.java create mode 100644 aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/TranslatorTest.java create mode 100644 aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/UpdateHandlerTest.java create mode 100644 aws-sagemaker-userprofile/template.yml diff --git a/README.md b/README.md index 66d1fd2..5c3bf6c 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ This repository contains code to manage the following SageMaker resource provide - AWS::SageMaker::MonitoringSchedule - AWS::SageMaker::Image - AWS::SageMaker::ImageVersion +- AWS::SageMaker::Domain +- AWS::SageMaker::UserProfile +- AWS::SageMaker::App +- AWS::SageMaker::AppImageConfig ## Security diff --git a/aws-sagemaker-app/.rpdk-config b/aws-sagemaker-app/.rpdk-config new file mode 100644 index 0000000..6cd7964 --- /dev/null +++ b/aws-sagemaker-app/.rpdk-config @@ -0,0 +1,17 @@ +{ + "typeName": "AWS::SageMaker::App", + "language": "java", + "runtime": "java8", + "entrypoint": "software.amazon.sagemaker.app.HandlerWrapper::handleRequest", + "testEntrypoint": "software.amazon.sagemaker.app.HandlerWrapper::testEntrypoint", + "settings": { + "namespace": [ + "software", + "amazon", + "sagemaker", + "app" + ], + "codegen_template_path": "default", + "protocolVersion": "2.0.0" + } +} diff --git a/aws-sagemaker-app/README.md b/aws-sagemaker-app/README.md new file mode 100644 index 0000000..119c977 --- /dev/null +++ b/aws-sagemaker-app/README.md @@ -0,0 +1,12 @@ +# AWS::SageMaker::App + +Congratulations on starting development! Next steps: + +1. Write the JSON schema describing your resource, `aws-sagemaker-app.json` +1. Implement your resource handlers. + +The RPDK will automatically generate the correct resource model from the schema whenever the project is built via Maven. You can also do this manually with the following command: `cfn generate`. + +> Please don't modify files under `target/generated-sources/rpdk`, as they will be automatically overwritten. + +The code uses [Lombok](https://projectlombok.org/), and [you may have to install IDE integrations](https://projectlombok.org/setup/overview) to enable auto-complete for Lombok-annotated classes. diff --git a/aws-sagemaker-app/aws-sagemaker-app.json b/aws-sagemaker-app/aws-sagemaker-app.json new file mode 100644 index 0000000..721365e --- /dev/null +++ b/aws-sagemaker-app/aws-sagemaker-app.json @@ -0,0 +1,184 @@ +{ + "typeName": "AWS::SageMaker::App", + "description": "Resource Type definition for AWS::SageMaker::App", + "additionalProperties": false, + "properties": { + "AppArn": { + "type": "string", + "description": "The Amazon Resource Name (ARN) of the app.", + "minLength": 1, + "maxLength": 256, + "pattern": "arn:aws[a-z\\-]*:sagemaker:[a-z0-9\\-]*:[0-9]{12}:app/.*" + }, + "AppName": { + "type": "string", + "description": "The name of the app.", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-zA-Z0-9](-*[a-zA-Z0-9]){0,62}" + }, + "AppType": { + "type": "string", + "description": "The type of app.", + "enum": [ + "JupyterServer", + "KernelGateway" + ] + }, + "DomainId": { + "type": "string", + "description": "The domain ID.", + "minLength": 1, + "maxLength": 63 + }, + "ResourceSpec": { + "$ref": "#/definitions/ResourceSpec", + "description": "The instance type and the Amazon Resource Name (ARN) of the SageMaker image created on the instance." + }, + "Tags": { + "type": "array", + "description": "A list of tags to apply to the app.", + "uniqueItems": false, + "minItems": 0, + "maxItems": 50, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "UserProfileName": { + "type": "string", + "description": "The user profile name.", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-zA-Z0-9](-*[a-zA-Z0-9]){0,62}" + } + }, + "definitions": { + "ResourceSpec": { + "type": "object", + "additionalProperties": false, + "properties": { + "InstanceType": { + "type": "string", + "description": "The instance type that the image version runs on.", + "enum": [ + "system", + "ml.t3.micro", + "ml.t3.small", + "ml.t3.medium", + "ml.t3.large", + "ml.t3.xlarge", + "ml.t3.2xlarge", + "ml.m5.large", + "ml.m5.xlarge", + "ml.m5.2xlarge", + "ml.m5.4xlarge", + "ml.m5.8xlarge", + "ml.m5.12xlarge", + "ml.m5.16xlarge", + "ml.m5.24xlarge", + "ml.c5.large", + "ml.c5.xlarge", + "ml.c5.2xlarge", + "ml.c5.4xlarge", + "ml.c5.9xlarge", + "ml.c5.12xlarge", + "ml.c5.18xlarge", + "ml.c5.24xlarge", + "ml.p3.2xlarge", + "ml.p3.8xlarge", + "ml.p3.16xlarge", + "ml.g4dn.xlarge", + "ml.g4dn.2xlarge", + "ml.g4dn.4xlarge", + "ml.g4dn.8xlarge", + "ml.g4dn.12xlarge", + "ml.g4dn.16xlarge" + ] + }, + "SageMakerImageArn": { + "type": "string", + "description": "The ARN of the SageMaker image that the image version belongs to.", + "minLength": 1, + "maxLength": 256, + "pattern": "^arn:aws(-[\\w]+)*:sagemaker:.+:[0-9]{12}:image/[a-z0-9]([-.]?[a-z0-9])*$" + }, + "SageMakerImageVersionArn": { + "type": "string", + "description": "The ARN of the image version created on the instance.", + "minLength": 1, + "maxLength": 256, + "pattern": "^arn:aws(-[\\w]+)*:sagemaker:.+:[0-9]{12}:image-version/[a-z0-9]([-.]?[a-z0-9])*/[0-9]+$" + } + } + }, + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "Key": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "Key", + "Value" + ] + } + }, + "required": [ + "AppName", + "AppType", + "DomainId", + "UserProfileName" + ], + "createOnlyProperties": [ + "/properties/AppName", + "/properties/AppType", + "/properties/DomainId", + "/properties/UserProfileName", + "/properties/Tags" + ], + "writeOnlyProperties": [ + "/properties/Tags" + ], + "primaryIdentifier": [ + "/properties/AppName", + "/properties/AppType", + "/properties/DomainId", + "/properties/UserProfileName" + ], + "readOnlyProperties": [ + "/properties/AppArn" + ], + "handlers": { + "create": { + "permissions": [ + "sagemaker:CreateApp", + "sagemaker:DescribeApp" + ] + }, + "read": { + "permissions": [ + "sagemaker:DescribeApp" + ] + }, + "delete": { + "permissions": [ + "sagemaker:DeleteApp" + ] + }, + "list": { + "permissions": [ + "sagemaker:ListApps" + ] + } + } +} \ No newline at end of file diff --git a/aws-sagemaker-app/docs/README.md b/aws-sagemaker-app/docs/README.md new file mode 100644 index 0000000..00efe7d --- /dev/null +++ b/aws-sagemaker-app/docs/README.md @@ -0,0 +1,128 @@ +# AWS::SageMaker::App + +Resource Type definition for AWS::SageMaker::App + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Type" : "AWS::SageMaker::App",
+    "Properties" : {
+        "AppName" : String,
+        "AppType" : String,
+        "DomainId" : String,
+        "ResourceSpec" : ResourceSpec,
+        "Tags" : [ Tag, ... ],
+        "UserProfileName" : String
+    }
+}
+
+ +### YAML + +
+Type: AWS::SageMaker::App
+Properties:
+    AppName: String
+    AppType: String
+    DomainId: String
+    ResourceSpec: ResourceSpec
+    Tags: 
+      - Tag
+    UserProfileName: String
+
+ +## Properties + +#### AppName + +The name of the app. + +_Required_: Yes + +_Type_: String + +_Minimum_: 1 + +_Maximum_: 63 + +_Pattern_: ^[a-zA-Z0-9](-*[a-zA-Z0-9]){0,62} + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### AppType + +The type of app. + +_Required_: Yes + +_Type_: String + +_Allowed Values_: JupyterServer | KernelGateway + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### DomainId + +The domain ID. + +_Required_: Yes + +_Type_: String + +_Minimum_: 1 + +_Maximum_: 63 + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### ResourceSpec + +_Required_: No + +_Type_: ResourceSpec + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Tags + +A list of tags to apply to the app. + +_Required_: No + +_Type_: List of Tag + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### UserProfileName + +The user profile name. + +_Required_: Yes + +_Type_: String + +_Minimum_: 1 + +_Maximum_: 63 + +_Pattern_: ^[a-zA-Z0-9](-*[a-zA-Z0-9]){0,62} + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +## Return Values + +### Fn::GetAtt + +The `Fn::GetAtt` intrinsic function returns a value for a specified attribute of this type. The following are the available attributes and sample return values. + +For more information about using the `Fn::GetAtt` intrinsic function, see [Fn::GetAtt](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getatt.html). + +#### AppArn + +The Amazon Resource Name (ARN) of the app. + diff --git a/aws-sagemaker-app/docs/resourcespec.md b/aws-sagemaker-app/docs/resourcespec.md new file mode 100644 index 0000000..05ec276 --- /dev/null +++ b/aws-sagemaker-app/docs/resourcespec.md @@ -0,0 +1,70 @@ +# AWS::SageMaker::App ResourceSpec + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "InstanceType" : String,
+    "SageMakerImageArn" : String,
+    "SageMakerImageVersionArn" : String
+}
+
+ +### YAML + +
+InstanceType: String
+SageMakerImageArn: String
+SageMakerImageVersionArn: String
+
+ +## Properties + +#### InstanceType + +The instance type that the image version runs on. + +_Required_: No + +_Type_: String + +_Allowed Values_: system | ml.t3.micro | ml.t3.small | ml.t3.medium | ml.t3.large | ml.t3.xlarge | ml.t3.2xlarge | ml.m5.large | ml.m5.xlarge | ml.m5.2xlarge | ml.m5.4xlarge | ml.m5.8xlarge | ml.m5.12xlarge | ml.m5.16xlarge | ml.m5.24xlarge | ml.c5.large | ml.c5.xlarge | ml.c5.2xlarge | ml.c5.4xlarge | ml.c5.9xlarge | ml.c5.12xlarge | ml.c5.18xlarge | ml.c5.24xlarge | ml.p3.2xlarge | ml.p3.8xlarge | ml.p3.16xlarge | ml.g4dn.xlarge | ml.g4dn.2xlarge | ml.g4dn.4xlarge | ml.g4dn.8xlarge | ml.g4dn.12xlarge | ml.g4dn.16xlarge + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### SageMakerImageArn + +The ARN of the SageMaker image that the image version belongs to. + +_Required_: No + +_Type_: String + +_Minimum_: 1 + +_Maximum_: 256 + +_Pattern_: ^arn:aws(-[\w]+)*:sagemaker:.+:[0-9]{12}:image/[a-z0-9]([-.]?[a-z0-9])*$ + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### SageMakerImageVersionArn + +The ARN of the image version created on the instance. + +_Required_: No + +_Type_: String + +_Minimum_: 1 + +_Maximum_: 256 + +_Pattern_: ^arn:aws(-[\w]+)*:sagemaker:.+:[0-9]{12}:image-version/[a-z0-9]([-.]?[a-z0-9])*/[0-9]+$ + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-sagemaker-app/docs/tag.md b/aws-sagemaker-app/docs/tag.md new file mode 100644 index 0000000..6f836ee --- /dev/null +++ b/aws-sagemaker-app/docs/tag.md @@ -0,0 +1,48 @@ +# AWS::SageMaker::App Tag + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Value" : String,
+    "Key" : String
+}
+
+ +### YAML + +
+Value: String
+Key: String
+
+ +## Properties + +#### Value + +_Required_: Yes + +_Type_: String + +_Minimum_: 1 + +_Maximum_: 128 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Key + +_Required_: Yes + +_Type_: String + +_Minimum_: 1 + +_Maximum_: 128 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-sagemaker-app/lombok.config b/aws-sagemaker-app/lombok.config new file mode 100644 index 0000000..7a21e88 --- /dev/null +++ b/aws-sagemaker-app/lombok.config @@ -0,0 +1 @@ +lombok.addLombokGeneratedAnnotation = true diff --git a/aws-sagemaker-app/pom.xml b/aws-sagemaker-app/pom.xml new file mode 100644 index 0000000..9e9d311 --- /dev/null +++ b/aws-sagemaker-app/pom.xml @@ -0,0 +1,210 @@ + + + 4.0.0 + + software.amazon.sagemaker.app + aws-sagemaker-app-handler + aws-sagemaker-app-handler + 1.0-SNAPSHOT + jar + + + 1.8 + 1.8 + UTF-8 + UTF-8 + + + + + + software.amazon.awssdk + sagemaker + 2.15.41 + + + + software.amazon.cloudformation + aws-cloudformation-rpdk-java-plugin + [2.0.0,3.0.0) + + + + org.projectlombok + lombok + 1.18.4 + provided + + + + + org.assertj + assertj-core + 3.12.2 + test + + + + org.junit.jupiter + junit-jupiter + 5.5.0-M1 + test + + + + org.mockito + mockito-core + 3.6.0 + test + + + + org.mockito + mockito-junit-jupiter + 3.6.0 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + -Xlint:all,-options,-processing + -Werror + + + + + org.apache.maven.plugins + maven-shade-plugin + 2.3 + + false + + + + package + + shade + + + + + + org.codehaus.mojo + exec-maven-plugin + 1.6.0 + + + generate + generate-sources + + exec + + + cfn + generate + ${project.basedir} + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.0.0 + + + add-source + generate-sources + + add-source + + + + ${project.basedir}/target/generated-sources/rpdk + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 2.4 + + + maven-surefire-plugin + 3.0.0-M3 + + + org.jacoco + jacoco-maven-plugin + 0.8.4 + + + **/BaseConfiguration* + **/BaseHandler* + **/HandlerWrapper* + **/ResourceModel* + + + + + + prepare-agent + + + + report + test + + report + + + + jacoco-check + + check + + + + + PACKAGE + + + BRANCH + COVEREDRATIO + 0.8 + + + INSTRUCTION + COVEREDRATIO + 0.8 + + + + + + + + + + + + ${project.basedir} + + aws-sagemaker-app.json + + + + + diff --git a/aws-sagemaker-app/resource-role.yaml b/aws-sagemaker-app/resource-role.yaml new file mode 100644 index 0000000..cb1ca2b --- /dev/null +++ b/aws-sagemaker-app/resource-role.yaml @@ -0,0 +1,34 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: > + This CloudFormation template creates a role assumed by CloudFormation + during CRUDL operations to mutate resources on behalf of the customer. + +Resources: + ExecutionRole: + Type: AWS::IAM::Role + Properties: + MaxSessionDuration: 8400 + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: resources.cloudformation.amazonaws.com + Action: sts:AssumeRole + Path: "/" + Policies: + - PolicyName: ResourceTypePolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - "sagemaker:CreateApp" + - "sagemaker:DeleteApp" + - "sagemaker:DescribeApp" + - "sagemaker:ListApps" + Resource: "*" +Outputs: + ExecutionRoleArn: + Value: + Fn::GetAtt: ExecutionRole.Arn diff --git a/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/BaseHandlerStd.java b/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/BaseHandlerStd.java new file mode 100644 index 0000000..e552802 --- /dev/null +++ b/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/BaseHandlerStd.java @@ -0,0 +1,36 @@ +package software.amazon.sagemaker.app; + +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +/** + * Common handler function definition for Create/Read/Update/Delete/List handlers. + */ +public abstract class BaseHandlerStd extends BaseHandler { + + @Override + public final ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final Logger logger) { + return handleRequest( + proxy, + request, + callbackContext != null ? callbackContext : new CallbackContext(), + proxy.newProxy(ClientBuilder::getClient), + logger + ); + } + + protected abstract ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger); +} \ No newline at end of file diff --git a/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/CallbackContext.java b/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/CallbackContext.java new file mode 100644 index 0000000..6134e65 --- /dev/null +++ b/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/CallbackContext.java @@ -0,0 +1,10 @@ +package software.amazon.sagemaker.app; + +import software.amazon.cloudformation.proxy.StdCallbackContext; + +@lombok.Getter +@lombok.Setter +@lombok.ToString +@lombok.EqualsAndHashCode(callSuper = true) +public class CallbackContext extends StdCallbackContext { +} diff --git a/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/ClientBuilder.java b/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/ClientBuilder.java new file mode 100644 index 0000000..7c201bc --- /dev/null +++ b/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/ClientBuilder.java @@ -0,0 +1,12 @@ +package software.amazon.sagemaker.app; + +import software.amazon.awssdk.services.sagemaker.SageMakerClient; + +/** + * Provides APIs to build the service client. + */ +public class ClientBuilder { + public static SageMakerClient getClient() { + return SageMakerClient.builder().build(); + } +} diff --git a/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/Configuration.java b/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/Configuration.java new file mode 100644 index 0000000..f3ca966 --- /dev/null +++ b/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/Configuration.java @@ -0,0 +1,8 @@ +package software.amazon.sagemaker.app; + +class Configuration extends BaseConfiguration { + + public Configuration() { + super("aws-sagemaker-app.json"); + } +} diff --git a/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/CreateHandler.java b/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/CreateHandler.java new file mode 100644 index 0000000..266ca26 --- /dev/null +++ b/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/CreateHandler.java @@ -0,0 +1,133 @@ +package software.amazon.sagemaker.app; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.AppStatus; +import software.amazon.awssdk.services.sagemaker.model.CreateAppRequest; +import software.amazon.awssdk.services.sagemaker.model.CreateAppResponse; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnInvalidRequestException; +import software.amazon.cloudformation.exceptions.CfnNotStabilizedException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class CreateHandler extends BaseHandlerStd { + + private static final String OPERATION = "AWS-SageMaker-App::Create"; + private static final String READ_ONLY_PROPERTY_ERROR_MESSAGE = "The following property '%s' is not allowed to configured."; + + private Logger logger; + + protected ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + + final ResourceModel model = request.getDesiredResourceState(); + + // read only properties are not allowed to be set by the user during creation. + // https://github.com/aws-cloudformation/aws-cloudformation-resource-schema/issues/102 + if (callbackContext.callGraphs().isEmpty()) { + if (model.getAppArn() != null) { + throw new CfnInvalidRequestException(String.format(READ_ONLY_PROPERTY_ERROR_MESSAGE, "AppArn")); + } + } + + return ProgressEvent.progress(model, callbackContext) + .then(progress -> + proxy.initiate(OPERATION, proxyClient, model, callbackContext) + .translateToServiceRequest(TranslatorForRequest::translateToCreateRequest) + .makeServiceCall(this::createResource) + .stabilize(this::stabilizedOnCreate) + .done(createResponse -> constructResourceModelFromResponse(model, createResponse)) + ); + } + + /** + * Client invocation of the create request through the proxyClient. + * + * @param createRequest aws service create resource request + * @param proxyClient aws service client to make the call + * @return create resource response + */ + private CreateAppResponse createResource( + final CreateAppRequest createRequest, + final ProxyClient proxyClient) { + CreateAppResponse response = null; + try { + response = proxyClient.injectCredentialsAndInvokeV2( + createRequest, proxyClient.client()::createApp); + } catch (final AwsServiceException e) { + Translator.throwCfnException(Action.CREATE.toString(), ResourceModel.TYPE_NAME, createRequest.appName(), e); + } + return response; + } + + /** + * Ensure resource has moved from pending to terminal state. + * + * @param awsRequest the aws service create resource request + * @param awsResponse the aws service create response + * @param proxyClient the aws service client to make the call + * @param model Resource Model + * @param callbackContext call back context + * @return boolean to indicate if the creation is stabilized + */ + private boolean stabilizedOnCreate( + final CreateAppRequest awsRequest, + final CreateAppResponse awsResponse, + final ProxyClient proxyClient, + final ResourceModel model, + final CallbackContext callbackContext) { + + if (model.getAppName() == null) { + model.setAppName(awsRequest.appName()); + } + + final AppStatus AppStatus; + try { + AppStatus = proxyClient.injectCredentialsAndInvokeV2( + TranslatorForRequest.translateToReadRequest(model), + proxyClient.client()::describeApp).status(); + } catch (ResourceNotFoundException rnfe) { + logger.log(String.format("Resource not found for %s, stabilizing.", model.getPrimaryIdentifier())); + return false; + } + + switch (AppStatus) { + case IN_SERVICE: + logger.log(String.format("%s [%s] has been stabilized with status %s.", ResourceModel.TYPE_NAME, + model.getPrimaryIdentifier(), AppStatus)); + return true; + case PENDING: + logger.log(String.format("%s [%s] is stabilizing.", ResourceModel.TYPE_NAME, + model.getPrimaryIdentifier())); + return false; + default: + logger.log(String.format("%s [%s] failed to stabilize with status: %s.", ResourceModel.TYPE_NAME, + model.getPrimaryIdentifier(), AppStatus)); + throw new CfnNotStabilizedException(ResourceModel.TYPE_NAME, model.getAppName()); + } + } + + /** + * Build the Progress Event object from the create response. + * + * @param model resource model + * @param awsResponse aws service create resource response + * @return progressEvent indicating success + */ + private ProgressEvent constructResourceModelFromResponse( + final ResourceModel model, final CreateAppResponse awsResponse) { + model.setAppArn(awsResponse.appArn()); + return ProgressEvent.defaultSuccessHandler(model); + } +} diff --git a/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/DeleteHandler.java b/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/DeleteHandler.java new file mode 100644 index 0000000..e91cce7 --- /dev/null +++ b/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/DeleteHandler.java @@ -0,0 +1,132 @@ +package software.amazon.sagemaker.app; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.AppStatus; +import software.amazon.awssdk.services.sagemaker.model.DeleteAppRequest; +import software.amazon.awssdk.services.sagemaker.model.DeleteAppResponse; +import software.amazon.awssdk.services.sagemaker.model.DescribeAppRequest; +import software.amazon.awssdk.services.sagemaker.model.ResourceInUseException; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.exceptions.CfnNotStabilizedException; +import software.amazon.cloudformation.exceptions.CfnResourceConflictException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class DeleteHandler extends BaseHandlerStd { + + private static final String OPERATION = "AWS-SageMaker-App::Delete"; + + private Logger logger; + + protected ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + + final ResourceModel model = request.getDesiredResourceState(); + + return ProgressEvent.progress(model, callbackContext) + .then(progress -> + proxy.initiate(OPERATION, proxyClient, model, callbackContext) + .translateToServiceRequest(TranslatorForRequest::translateToDeleteRequest) + .makeServiceCall((_awsRequest, _proxyClient) -> + deleteResource(TranslatorForRequest.translateToReadRequest(model), _awsRequest, _proxyClient)) + .stabilize(this::stabilizedOnDelete) + .done(awsResponse -> ProgressEvent.builder() + .status(OperationStatus.SUCCESS) + .build())); + } + + /** + * Client invocation of the delete request through the proxyClient. + * + * @param deleteRequest the aws service delete resource request + * @param proxyClient the aws service client to make the call + * @return delete resource response + */ + private DeleteAppResponse deleteResource( + final DescribeAppRequest describeRequest, + final DeleteAppRequest deleteRequest, + final ProxyClient proxyClient) { + AppStatus appStatus = null; + try { + appStatus = proxyClient.injectCredentialsAndInvokeV2(describeRequest, proxyClient.client()::describeApp).status(); + } catch (final AwsServiceException e) { + Translator.throwCfnException(Action.READ.toString(), ResourceModel.TYPE_NAME, + deleteRequest.appName(), e); + } + + // Deleted Apps stay present for 24 hours with a Deleted status. + // Deleted resources are expected to throw CfnNotFoundException. + if (appStatus.equals(AppStatus.DELETED) || appStatus.equals(AppStatus.FAILED)) { + throw new CfnNotFoundException(ResourceModel.TYPE_NAME, deleteRequest.appName()); + } + + DeleteAppResponse response = null; + try { + response = proxyClient.injectCredentialsAndInvokeV2(deleteRequest, proxyClient.client()::deleteApp); + } catch (final ResourceInUseException riue) { + // ResourceInUseException is handled differently for deletes + final String primaryIdentifier = String.format("%s|%s|%s|%s", + deleteRequest.appName(), + deleteRequest.appType(), + deleteRequest.domainId(), + deleteRequest.userProfileName()); + throw new CfnResourceConflictException(ResourceModel.TYPE_NAME, primaryIdentifier, riue.getMessage(), riue); + } catch (final AwsServiceException e) { + Translator.throwCfnException(Action.DELETE.toString(), ResourceModel.TYPE_NAME, + deleteRequest.appName(), e); + } + + return response; + } + + /** + * Ensure resource has moved from pending to terminal state. + * + * @param awsRequest the aws service delete resource request + * @param awsResponse the aws service delete resource response + * @param proxyClient the aws service client to make the call + * @param model resource model + * @param callbackContext callback context + * @return boolean to indicate if the deletion is stabilized + */ + private boolean stabilizedOnDelete( + final DeleteAppRequest awsRequest, + final DeleteAppResponse awsResponse, + final ProxyClient proxyClient, + final ResourceModel model, + final CallbackContext callbackContext) { + try { + final AppStatus AppStatus = + proxyClient.injectCredentialsAndInvokeV2(TranslatorForRequest.translateToReadRequest(model), + proxyClient.client()::describeApp).status(); + + switch (AppStatus) { + case DELETED: + logger.log(String.format("%s with name [%s] is stabilized.", + ResourceModel.TYPE_NAME, model.getPrimaryIdentifier())); + return true; + case DELETING: + logger.log(String.format("%s with name [%s] is stabilizing while delete.", + ResourceModel.TYPE_NAME, model.getPrimaryIdentifier())); + return false; + default: + throw new CfnNotStabilizedException(ResourceModel.TYPE_NAME, model.getAppName()); + } + } catch (ResourceNotFoundException e) { + return true; + } + } +} diff --git a/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/ListHandler.java b/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/ListHandler.java new file mode 100644 index 0000000..16cac0b --- /dev/null +++ b/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/ListHandler.java @@ -0,0 +1,74 @@ +package software.amazon.sagemaker.app; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.ListAppsRequest; +import software.amazon.awssdk.services.sagemaker.model.ListAppsResponse; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class ListHandler extends BaseHandlerStd { + + private static final String OPERATION = "AWS-SageMaker-App::List"; + + private Logger logger; + + @Override + public ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + + final ResourceModel model = request.getDesiredResourceState(); + + return proxy.initiate(OPERATION, proxyClient, model, callbackContext) + .translateToServiceRequest(resourceModel -> TranslatorForRequest.translateToListRequest(request.getNextToken())) + .makeServiceCall(this::listResources) + .done(this::constructResourceModelFromResponse); + } + + /** + * Client invocation of the list request through the proxyClient. + * + * @param awsRequest the aws service request to list a resource + * @param proxyClient the aws service client to make the call + * @return response ListAppsResponse + */ + private ListAppsResponse listResources( + final ListAppsRequest awsRequest, + final ProxyClient proxyClient) { + + ListAppsResponse response = null; + try { + response = proxyClient.injectCredentialsAndInvokeV2(awsRequest, proxyClient.client()::listApps); + } catch (final AwsServiceException e) { + Translator.throwCfnException(Action.LIST.toString(), ResourceModel.TYPE_NAME, null, e); + } + + return response; + } + + /** + * Build the Progress Event object from the list resource response. + * + * @param listResponse the aws service list resource response + * @return progressEvent indicating success, in progress, delay, callback or failed state + */ + private ProgressEvent constructResourceModelFromResponse( + final ListAppsResponse listResponse) { + return ProgressEvent.builder() + .nextToken(listResponse.nextToken()) + .resourceModels(TranslatorForResponse.translateFromListResponse(listResponse)) + .status(OperationStatus.SUCCESS) + .build(); + } +} diff --git a/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/ReadHandler.java b/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/ReadHandler.java new file mode 100644 index 0000000..1541701 --- /dev/null +++ b/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/ReadHandler.java @@ -0,0 +1,80 @@ +package software.amazon.sagemaker.app; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.AppStatus; +import software.amazon.awssdk.services.sagemaker.model.DescribeAppRequest; +import software.amazon.awssdk.services.sagemaker.model.DescribeAppResponse; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class ReadHandler extends BaseHandlerStd { + + private static final String OPERATION = "AWS-SageMaker-App::Read"; + + private Logger logger; + + + @Override + public ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + final ResourceModel model = request.getDesiredResourceState(); + + return proxy.initiate(OPERATION, proxyClient, model, callbackContext) + .translateToServiceRequest(TranslatorForRequest::translateToReadRequest) + .makeServiceCall((awsRequest, sdkProxyClient) -> readResource(awsRequest, sdkProxyClient, model)) + .done(this::constructResourceModelFromResponse); + } + + /** + * Client invocation of the read request through the proxyClient. + * + * @param awsRequest the aws service describe resource request + * @param proxyClient the aws service client to make the call + * @param model Resource Model + * @return describe resource response + */ + private DescribeAppResponse readResource( + final DescribeAppRequest awsRequest, + final ProxyClient proxyClient, + final ResourceModel model) { + + DescribeAppResponse response = null; + try { + response = proxyClient.injectCredentialsAndInvokeV2(awsRequest, proxyClient.client()::describeApp); + } catch (final AwsServiceException e) { + Translator.throwCfnException(Action.READ.toString(), ResourceModel.TYPE_NAME, + awsRequest.appName(), e); + } + + // Deleted Apps stay present for 24 hours with a Deleted status. + // Deleted resources are expected to throw CfnNotFoundException. + if (response.status().equals(AppStatus.DELETED)) { + throw new CfnNotFoundException(ResourceModel.TYPE_NAME, awsRequest.appName()); + } + + return response; + } + + /** + * Implement client invocation of the read request through the proxyClient. + * + * @param awsResponse the aws service describe resource response + * @return progressEvent indicating success, in progress, delay, callback or failed state + */ + private ProgressEvent constructResourceModelFromResponse( + final DescribeAppResponse awsResponse) { + return ProgressEvent.defaultSuccessHandler(TranslatorForResponse.translateFromReadResponse(awsResponse)); + } +} diff --git a/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/Translator.java b/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/Translator.java new file mode 100644 index 0000000..30ed172 --- /dev/null +++ b/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/Translator.java @@ -0,0 +1,81 @@ +package software.amazon.sagemaker.app; + +import org.apache.commons.lang3.StringUtils; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.model.ResourceInUseException; +import software.amazon.awssdk.services.sagemaker.model.ResourceLimitExceededException; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.cloudformation.exceptions.CfnAccessDeniedException; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnInvalidRequestException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.exceptions.CfnServiceInternalErrorException; +import software.amazon.cloudformation.exceptions.CfnServiceLimitExceededException; +import software.amazon.cloudformation.exceptions.CfnThrottlingException; +import software.amazon.cloudformation.exceptions.ResourceAlreadyExistsException; + +import java.util.Collection; +import java.util.Optional; +import java.util.stream.Stream; + +/** + * Contains common methods required by other translators. + */ +public class Translator { + + /** + * Throws a Cfn exception for the corresponding error code of the given exception. + * + * @param operation operation + * @param resourceType resource type + * @param resourceName resource name + * @param e exception + */ + static void throwCfnException( + final String operation, + final String resourceType, + final String resourceName, + final AwsServiceException e + ) { + + if (e instanceof ResourceInUseException) { + throw new ResourceAlreadyExistsException(resourceType, resourceName, e); + } + + if (e instanceof ResourceNotFoundException) { + throw new CfnNotFoundException(resourceType, resourceName, e); + } + + if (e instanceof ResourceLimitExceededException) { + throw new CfnServiceLimitExceededException(resourceType, e.getMessage(), e); + } + + if(e.awsErrorDetails() != null && StringUtils.isNotBlank(e.awsErrorDetails().errorCode())) { + String errorMessage = e.awsErrorDetails().errorMessage(); + switch (e.awsErrorDetails().errorCode()) { + case "UnauthorizedOperation": + throw new CfnAccessDeniedException(errorMessage, e); + case "InvalidParameter": + case "InvalidParameterValue": + case "ValidationError": + case "ValidationException": + throw new CfnInvalidRequestException(errorMessage, e); + case "InternalError": + case "ServiceUnavailable": + throw new CfnServiceInternalErrorException(errorMessage, e); + case "ThrottlingException": + throw new CfnThrottlingException(errorMessage, e); + default: + throw new CfnGeneralServiceException(errorMessage, e); + } + } + + throw new CfnGeneralServiceException(operation, e); + } + + static Stream streamOfOrEmpty(final Collection collection) { + return Optional.ofNullable(collection) + .map(Collection::stream) + .orElseGet(Stream::empty); + } +} \ No newline at end of file diff --git a/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/TranslatorForRequest.java b/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/TranslatorForRequest.java new file mode 100644 index 0000000..1b384cc --- /dev/null +++ b/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/TranslatorForRequest.java @@ -0,0 +1,89 @@ +package software.amazon.sagemaker.app; + +import software.amazon.awssdk.services.sagemaker.model.CreateAppRequest; +import software.amazon.awssdk.services.sagemaker.model.DeleteAppRequest; +import software.amazon.awssdk.services.sagemaker.model.DescribeAppRequest; +import software.amazon.awssdk.services.sagemaker.model.ListAppsRequest; +import software.amazon.awssdk.services.sagemaker.model.Tag; + +import java.util.stream.Collectors; + +final class TranslatorForRequest { + + private TranslatorForRequest() {} + + /** + * Translates ResourceModel input to an aws sdk create resource request. + * + * @param model resource model + * @return aws sdk create resource request + */ + static CreateAppRequest translateToCreateRequest(final ResourceModel model) { + return CreateAppRequest.builder() + .appName(model.getAppName()) + .appType(model.getAppType()) + .domainId(model.getDomainId()) + .resourceSpec(translateResourceSpec(model.getResourceSpec())) + .userProfileName(model.getUserProfileName()) + .tags(Translator.streamOfOrEmpty(model.getTags()) + .map(t -> Tag.builder() + .key(t.getKey()) + .value(t.getValue()) + .build()) + .collect(Collectors.toList()) + ).build(); + } + + /** + * Translates ResourceModel input to an aws sdk read resource request. + * + * @param model resource model + * @return aws sdk read resource request + */ + static DescribeAppRequest translateToReadRequest(final ResourceModel model) { + return DescribeAppRequest.builder() + .appName(model.getAppName()) + .appType(model.getAppType()) + .domainId(model.getDomainId()) + .userProfileName(model.getUserProfileName()) + .build(); + } + + /** + * Translates ResourceModel input to an aws sdk delete resource request. + * + * @param model resource model + * @return aws sdk delete resource request + */ + static DeleteAppRequest translateToDeleteRequest(final ResourceModel model) { + return DeleteAppRequest.builder() + .appName(model.getAppName()) + .appType(model.getAppType()) + .domainId(model.getDomainId()) + .userProfileName(model.getUserProfileName()) + .build(); + } + + /** + * Translates ResourceModel input to an aws sdk list resource request. + * + * @param nextToken token passed to the aws service describe resource request + * @return list resource request + */ + static ListAppsRequest translateToListRequest(final String nextToken) { + return ListAppsRequest.builder().nextToken(nextToken).build(); + } + + private static software.amazon.awssdk.services.sagemaker.model.ResourceSpec translateResourceSpec( + ResourceSpec origin) { + if (origin == null) { + return null; + } + + return software.amazon.awssdk.services.sagemaker.model.ResourceSpec.builder() + .instanceType(origin.getInstanceType()) + .sageMakerImageArn(origin.getSageMakerImageArn()) + .sageMakerImageVersionArn(origin.getSageMakerImageVersionArn()) + .build(); + } +} \ No newline at end of file diff --git a/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/TranslatorForResponse.java b/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/TranslatorForResponse.java new file mode 100644 index 0000000..4b89620 --- /dev/null +++ b/aws-sagemaker-app/src/main/java/software/amazon/sagemaker/app/TranslatorForResponse.java @@ -0,0 +1,62 @@ +package software.amazon.sagemaker.app; + +import software.amazon.awssdk.services.sagemaker.model.AppStatus; +import software.amazon.awssdk.services.sagemaker.model.DescribeAppResponse; +import software.amazon.awssdk.services.sagemaker.model.ListAppsResponse; +import software.amazon.awssdk.services.sagemaker.model.ResourceSpec; + +import java.util.List; +import java.util.stream.Collectors; + +public class TranslatorForResponse { + + private TranslatorForResponse() {} + + /** + * Translates the AWS SDK read response into a native resource model. + * + * @param awsResponse the aws service describe resource response + * @return model resource model + */ + static ResourceModel translateFromReadResponse(final DescribeAppResponse awsResponse) { + return ResourceModel.builder() + .appArn(awsResponse.appArn()) + .appName(awsResponse.appName()) + .appType(awsResponse.appTypeAsString()) + .domainId(awsResponse.domainId()) + .resourceSpec(translateResourceSpec(awsResponse.resourceSpec())) + .userProfileName(awsResponse.userProfileName()) + .build(); + } + + /** + * Translates the AWS SDK list response into a native resource model. + * + * @param awsResponse the aws service list resource response + * @return list of resource models + */ + static List translateFromListResponse(final ListAppsResponse awsResponse) { + return Translator.streamOfOrEmpty(awsResponse.apps()) + .filter(App -> !App.status().equals(AppStatus.DELETED) && !App.status().equals(AppStatus.FAILED)) + .map(App -> ResourceModel.builder() + .appName(App.appName()) + .appType(App.appTypeAsString()) + .domainId(App.domainId()) + .userProfileName(App.userProfileName()) + .build()) + .collect(Collectors.toList()); + } + + static software.amazon.sagemaker.app.ResourceSpec translateResourceSpec( + ResourceSpec origin) { + if (origin == null) { + return null; + } + + return software.amazon.sagemaker.app.ResourceSpec.builder() + .instanceType(origin.instanceTypeAsString()) + .sageMakerImageArn(origin.sageMakerImageArn()) + .sageMakerImageVersionArn(origin.sageMakerImageVersionArn()) + .build(); + } +} diff --git a/aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/AbstractTestBase.java b/aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/AbstractTestBase.java new file mode 100644 index 0000000..2d339c6 --- /dev/null +++ b/aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/AbstractTestBase.java @@ -0,0 +1,57 @@ +package software.amazon.sagemaker.app; + +import software.amazon.awssdk.awscore.AwsRequest; +import software.amazon.awssdk.awscore.AwsResponse; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.AppType; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Credentials; +import software.amazon.cloudformation.proxy.LoggerProxy; +import software.amazon.cloudformation.proxy.ProxyClient; + +import java.time.Instant; +import java.util.function.Function; + +public class AbstractTestBase { + protected static final String TEST_USER_PROFILE_NAME = "testUserProfileName"; + protected static final String TEST_DOMAIN_ID = "testDomainId"; + protected static final String TEST_APP_TYPE = AppType.JUPYTER_SERVER.toString(); + protected static final String TEST_APP_NAME = "testAppName"; + protected static final String TEST_APP_ARN = "testAppArn"; + protected static final String TEST_INSTANCE_TYPE = "testInstanceType"; + protected static final String TEST_IMAGE_ARN = "testImageArn"; + protected static final String TEST_IMAGE_VERSION_ARN = "testImageVersionArn"; + protected static final String TEST_ERROR_MESSAGE = "test error message"; + protected static final Credentials MOCK_CREDENTIALS; + protected static final LoggerProxy logger; + + static { + MOCK_CREDENTIALS = new Credentials("accessKey", "secretKey", "token"); + logger = new LoggerProxy(); + } + static ProxyClient MOCK_PROXY( + final AmazonWebServicesClientProxy proxy, + final SageMakerClient sagemakerClient) { + return new ProxyClient() { + @Override + public ResponseT + injectCredentialsAndInvokeV2(RequestT request, Function requestFunction) { + return proxy.injectCredentialsAndInvokeV2(request, requestFunction); + } + + @Override + public SageMakerClient client() { + return sagemakerClient; + } + }; + } + + protected static ResourceModel getRequestResourceModel() { + return ResourceModel.builder() + .appName(TEST_APP_NAME) + .appType(TEST_APP_TYPE) + .domainId(TEST_DOMAIN_ID) + .userProfileName(TEST_USER_PROFILE_NAME) + .build(); + } +} \ No newline at end of file diff --git a/aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/CreateHandlerTest.java b/aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/CreateHandlerTest.java new file mode 100644 index 0000000..01bae8b --- /dev/null +++ b/aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/CreateHandlerTest.java @@ -0,0 +1,337 @@ +package software.amazon.sagemaker.app; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsErrorDetails; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.AppStatus; +import software.amazon.awssdk.services.sagemaker.model.CreateAppRequest; +import software.amazon.awssdk.services.sagemaker.model.CreateAppResponse; +import software.amazon.awssdk.services.sagemaker.model.DescribeAppRequest; +import software.amazon.awssdk.services.sagemaker.model.DescribeAppResponse; +import software.amazon.awssdk.services.sagemaker.model.ResourceInUseException; +import software.amazon.awssdk.services.sagemaker.model.ResourceLimitExceededException; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnInvalidRequestException; +import software.amazon.cloudformation.exceptions.CfnNotStabilizedException; +import software.amazon.cloudformation.exceptions.CfnServiceLimitExceededException; +import software.amazon.cloudformation.exceptions.ResourceAlreadyExistsException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class CreateHandlerTest extends software.amazon.sagemaker.app.AbstractTestBase { + + private final ResourceSpec RESOURCE_SPEC = ResourceSpec.builder() + .instanceType(TEST_INSTANCE_TYPE) + .sageMakerImageArn(TEST_IMAGE_ARN) + .sageMakerImageVersionArn(TEST_IMAGE_VERSION_ARN) + .build(); + + private final ResourceModel REQUEST_MODEL = ResourceModel.builder() + .userProfileName(TEST_USER_PROFILE_NAME) + .resourceSpec(RESOURCE_SPEC) + .domainId(TEST_DOMAIN_ID) + .appType(TEST_APP_TYPE) + .appName(TEST_APP_NAME) + .build(); + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + SageMakerClient sdkClient; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + sdkClient = mock(SageMakerClient.class); + proxyClient = MOCK_PROXY(proxy, sdkClient); + } + + @Test + public void testCreateHandler_SimpleSuccess() { + final software.amazon.awssdk.services.sagemaker.model.ResourceSpec resourceSpec = + software.amazon.awssdk.services.sagemaker.model.ResourceSpec.builder() + .instanceType(TEST_INSTANCE_TYPE) + .sageMakerImageArn(TEST_IMAGE_ARN) + .sageMakerImageVersionArn(TEST_IMAGE_VERSION_ARN) + .build(); + + final DescribeAppResponse describeAppResponse = + DescribeAppResponse.builder() + .appArn(TEST_APP_ARN) + .appName(TEST_APP_NAME) + .appType(TEST_APP_TYPE) + .domainId(TEST_DOMAIN_ID) + .userProfileName(TEST_USER_PROFILE_NAME) + .resourceSpec(resourceSpec) + .status(AppStatus.IN_SERVICE) + .build(); + + final CreateAppResponse createResponse = CreateAppResponse.builder() + .appArn(TEST_APP_ARN) + .build(); + + when(proxyClient.client().describeApp(any(DescribeAppRequest.class))) + .thenReturn(describeAppResponse); + when(proxyClient.client().createApp(any(CreateAppRequest.class))) + .thenReturn(createResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + final ProgressEvent response = invokeHandleRequest(request); + + ResourceModel expectedModelFromResponse = ResourceModel.builder() + .appArn(TEST_APP_ARN) + .appName(TEST_APP_NAME) + .appType(TEST_APP_TYPE) + .domainId(TEST_DOMAIN_ID) + .userProfileName(TEST_USER_PROFILE_NAME) + .resourceSpec(RESOURCE_SPEC) + .build(); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isEqualTo(expectedModelFromResponse); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testCreateHandler_InvalidRequestArn() { + final ResourceModel invalidModel = ResourceModel.builder() + .appArn(TEST_APP_ARN) + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(invalidModel) + .build(); + + Exception exception = assertThrows( CfnInvalidRequestException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.InvalidRequest.getMessage(), + "The following property 'AppArn' is not allowed to configured.")); + } + + @Test + public void testCreateHandler_ServiceInternalException() { + final AwsServiceException serviceInternalException = SageMakerException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(500) + .build(); + + when(proxyClient.client().createApp(any(CreateAppRequest.class))) + .thenThrow(serviceInternalException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows( CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.CREATE)); + } + + @Test + public void testCreateHandler_ResourceAlreadyExists_Fails() { + final ResourceInUseException resourceInUseException = ResourceInUseException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(400) + .build(); + + when(proxyClient.client().createApp(any(CreateAppRequest.class))) + .thenThrow(resourceInUseException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows( ResourceAlreadyExistsException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.AlreadyExists.getMessage(), + ResourceModel.TYPE_NAME, TEST_APP_NAME)); + } + + @Test + public void testCreateHandler_ResourceLimitExceededException() { + final ResourceLimitExceededException resourceLimitExceededException = ResourceLimitExceededException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(400) + .build(); + + when(proxyClient.client().createApp(any(CreateAppRequest.class))) + .thenThrow(resourceLimitExceededException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows(CfnServiceLimitExceededException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.ServiceLimitExceeded.getMessage(), + ResourceModel.TYPE_NAME, TEST_ERROR_MESSAGE)); + } + + @Test + public void testCreateHandler_ValidationFailure() { + final AwsErrorDetails awsErrorDetails = + AwsErrorDetails.builder().errorCode("ValidationError").errorMessage(TEST_ERROR_MESSAGE).build(); + + final AwsServiceException validationFailureException = SageMakerException.builder() + .awsErrorDetails(awsErrorDetails) + .build(); + + when(proxyClient.client().createApp(any(CreateAppRequest.class))) + .thenThrow(validationFailureException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows( CfnInvalidRequestException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.InvalidRequest.getMessage(), + TEST_ERROR_MESSAGE)); + } + + @Test + public void testCreateHandler_NoExceptionMessage() { + final AwsServiceException someException = SageMakerException.builder() + .statusCode(400) + .build(); + + when(proxyClient.client().createApp(any(CreateAppRequest.class))) + .thenThrow(someException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows( CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.CREATE)); + } + + @Test + public void testCreateHandler_VerifyStabilization_InService() { + final software.amazon.awssdk.services.sagemaker.model.ResourceSpec resourceSpec = + software.amazon.awssdk.services.sagemaker.model.ResourceSpec.builder() + .instanceType(TEST_INSTANCE_TYPE) + .sageMakerImageArn(TEST_IMAGE_ARN) + .sageMakerImageVersionArn(TEST_IMAGE_VERSION_ARN) + .build(); + + final DescribeAppResponse firstDescribeResponse = + DescribeAppResponse.builder() + .appArn(TEST_APP_ARN) + .appName(TEST_APP_NAME) + .appType(TEST_APP_TYPE) + .domainId(TEST_DOMAIN_ID) + .userProfileName(TEST_USER_PROFILE_NAME) + .resourceSpec(resourceSpec) + .status(AppStatus.PENDING) + .build(); + + final DescribeAppResponse secondDescribeResponse = + DescribeAppResponse.builder() + .appArn(TEST_APP_ARN) + .appName(TEST_APP_NAME) + .appType(TEST_APP_TYPE) + .domainId(TEST_DOMAIN_ID) + .userProfileName(TEST_USER_PROFILE_NAME) + .resourceSpec(resourceSpec) + .status(AppStatus.IN_SERVICE) + .build(); + + final CreateAppResponse createAppResponse = CreateAppResponse.builder() + .appArn(TEST_APP_ARN) + .build(); + + when(proxyClient.client().describeApp(any(DescribeAppRequest.class))) + .thenReturn(firstDescribeResponse).thenReturn(secondDescribeResponse); + when(proxyClient.client().createApp(any(CreateAppRequest.class))) + .thenReturn(createAppResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + final ProgressEvent response = invokeHandleRequest(request); + + ResourceModel expectedModelFromResponse = ResourceModel.builder() + .appArn(TEST_APP_ARN) + .userProfileName(TEST_USER_PROFILE_NAME) + .resourceSpec(RESOURCE_SPEC) + .domainId(TEST_DOMAIN_ID) + .appType(TEST_APP_TYPE) + .appName(TEST_APP_NAME) + .build(); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isEqualTo(expectedModelFromResponse); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testCreateHandler_VerifyStabilization_Failed() { + final DescribeAppResponse firstDescribeResponse = + DescribeAppResponse.builder() + .status(AppStatus.PENDING) + .build(); + + final DescribeAppResponse secondDescribeResponse = + DescribeAppResponse.builder() + .status(AppStatus.FAILED) + .build(); + + final CreateAppResponse createAppResponse = CreateAppResponse.builder().build(); + + when(proxyClient.client().describeApp(any(DescribeAppRequest.class))) + .thenReturn(firstDescribeResponse).thenReturn(secondDescribeResponse); + when(proxyClient.client().createApp(any(CreateAppRequest.class))) + .thenReturn(createAppResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows(CfnNotStabilizedException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()). + isEqualTo(String.format(HandlerErrorCode.NotStabilized.getMessage(), ResourceModel.TYPE_NAME, TEST_APP_NAME)); + } + + private ProgressEvent invokeHandleRequest(ResourceHandlerRequest request) { + final CreateHandler handler = new CreateHandler(); + return handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + } +} \ No newline at end of file diff --git a/aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/DeleteHandlerTest.java b/aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/DeleteHandlerTest.java new file mode 100644 index 0000000..b7dfd19 --- /dev/null +++ b/aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/DeleteHandlerTest.java @@ -0,0 +1,282 @@ +package software.amazon.sagemaker.app; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.AppStatus; +import software.amazon.awssdk.services.sagemaker.model.DeleteAppRequest; +import software.amazon.awssdk.services.sagemaker.model.DeleteAppResponse; +import software.amazon.awssdk.services.sagemaker.model.DescribeAppRequest; +import software.amazon.awssdk.services.sagemaker.model.DescribeAppResponse; +import software.amazon.awssdk.services.sagemaker.model.ResourceInUseException; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.awssdk.services.sagemaker.model.ResourceSpec; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.exceptions.CfnNotStabilizedException; +import software.amazon.cloudformation.exceptions.CfnResourceConflictException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DeleteHandlerTest extends software.amazon.sagemaker.app.AbstractTestBase { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + SageMakerClient sdkClient; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + sdkClient = mock(SageMakerClient.class); + proxyClient = MOCK_PROXY(proxy, sdkClient); + } + + @Test + public void testDeleteHandler_SimpleSuccess() { + final DescribeAppResponse describeAppResponse = DescribeAppResponse.builder() + .status(AppStatus.IN_SERVICE) + .build(); + + final DeleteAppResponse deleteAppResponse = DeleteAppResponse.builder().build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + when(proxyClient.client().describeApp(any(DescribeAppRequest.class))) + .thenReturn(describeAppResponse).thenThrow(ResourceNotFoundException.class); + when(proxyClient.client().deleteApp(any(DeleteAppRequest.class))) + .thenReturn(deleteAppResponse); + + final ProgressEvent response = invokeHandleRequest(request); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo((OperationStatus.SUCCESS)); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + assertThat(response.getResourceModel()).isNull(); + } + + @Test + public void testDeleteHandler_ResourceNotFound_DeletedApp() { + final DescribeAppResponse describeAppResponse = DescribeAppResponse.builder() + .status(AppStatus.DELETED) + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + when(proxyClient.client().describeApp(any(DescribeAppRequest.class))) + .thenReturn(describeAppResponse); + + Exception exception = assertThrows(CfnNotFoundException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.NotFound.getMessage(), + ResourceModel.TYPE_NAME, TEST_APP_NAME)); + } + + @Test + public void testDeleteHandler_ServiceInternalException() { + final DescribeAppResponse describeAppResponse = DescribeAppResponse.builder() + .status(AppStatus.IN_SERVICE) + .build(); + + when(proxyClient.client().describeApp(any(DescribeAppRequest.class))) + .thenReturn(describeAppResponse); + + final AwsServiceException serviceInternalException = SageMakerException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(500) + .build(); + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + when(proxyClient.client().deleteApp(any(DeleteAppRequest.class))) + .thenThrow(serviceInternalException); + + Exception exception = assertThrows(CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.DELETE)); + } + + @Test + public void testDeleteHandler_ResourceInUse_Fails() { + final DescribeAppResponse describeAppResponse = DescribeAppResponse.builder() + .status(AppStatus.IN_SERVICE) + .build(); + + when(proxyClient.client().describeApp(any(DescribeAppRequest.class))) + .thenReturn(describeAppResponse); + when(proxyClient.client().deleteApp(any(DeleteAppRequest.class))) + .thenThrow(ResourceInUseException.class); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows(CfnResourceConflictException.class, () -> invokeHandleRequest(request)); + + final String primaryIdentifier = String.format("%s|%s|%s|%s", + TEST_APP_NAME, + TEST_APP_TYPE, + TEST_DOMAIN_ID, + TEST_USER_PROFILE_NAME + ); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.ResourceConflict.getMessage(), + ResourceModel.TYPE_NAME, primaryIdentifier, null)); + } + + @Test + public void testDeleteHandler_ResourceDoesNotExists_Fails() { + final DescribeAppResponse describeAppResponse = DescribeAppResponse.builder() + .status(AppStatus.IN_SERVICE) + .build(); + + when(proxyClient.client().describeApp(any(DescribeAppRequest.class))) + .thenReturn(describeAppResponse); + when(proxyClient.client().deleteApp(any(DeleteAppRequest.class))) + .thenThrow(ResourceNotFoundException.class); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows(CfnNotFoundException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.NotFound.getMessage(), + ResourceModel.TYPE_NAME, TEST_APP_NAME)); + } + + @Test + public void testDeleteHandler_VerifyStabilization_SuccessfulDelete() { + final software.amazon.awssdk.services.sagemaker.model.ResourceSpec resourceSpec = ResourceSpec.builder() + .instanceType(TEST_INSTANCE_TYPE) + .sageMakerImageArn(TEST_IMAGE_ARN) + .sageMakerImageVersionArn(TEST_IMAGE_VERSION_ARN) + .build(); + + final DescribeAppResponse firstDescribeResponse = + DescribeAppResponse.builder() + .appArn(TEST_APP_ARN) + .appName(TEST_APP_NAME) + .appType(TEST_APP_TYPE) + .domainId(TEST_DOMAIN_ID) + .userProfileName(TEST_USER_PROFILE_NAME) + .resourceSpec(resourceSpec) + .status(AppStatus.DELETING) + .build(); + + final DeleteAppResponse deleteAppResponse = DeleteAppResponse.builder() + .build(); + + when(proxyClient.client().describeApp(any(DescribeAppRequest.class))) + .thenReturn(firstDescribeResponse).thenThrow(ResourceNotFoundException.class); + when(proxyClient.client().deleteApp(any(DeleteAppRequest.class))) + .thenReturn(deleteAppResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + final ProgressEvent response = invokeHandleRequest(request); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testDeleteHandler_VerifyStabilization_SuccessfulDeletedStatus() { + final DescribeAppResponse firstDescribeResponse =DescribeAppResponse.builder() + .status(AppStatus.DELETING) + .build(); + + final DescribeAppResponse secondDescribeResponse = DescribeAppResponse.builder() + .status(AppStatus.DELETED) + .build(); + + final DeleteAppResponse deleteAppResponse = DeleteAppResponse.builder() + .build(); + + when(proxyClient.client().describeApp(any(DescribeAppRequest.class))) + .thenReturn(firstDescribeResponse).thenReturn(secondDescribeResponse); + when(proxyClient.client().deleteApp(any(DeleteAppRequest.class))) + .thenReturn(deleteAppResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + final ProgressEvent response = invokeHandleRequest(request); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testDeleteHandler_VerifyStabilization_ResourceNotDeleted() { + final DescribeAppResponse firstDescribeResponse =DescribeAppResponse.builder() + .status(AppStatus.DELETING) + .build(); + + final DescribeAppResponse secondDescribeResponse = DescribeAppResponse.builder() + .status(AppStatus.FAILED) + .build(); + + final DeleteAppResponse deleteAppResponse = DeleteAppResponse.builder() + .build(); + + when(proxyClient.client().describeApp(any(DescribeAppRequest.class))) + .thenReturn(firstDescribeResponse).thenReturn(secondDescribeResponse); + when(proxyClient.client().deleteApp(any(DeleteAppRequest.class))) + .thenReturn(deleteAppResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows( CfnNotStabilizedException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.NotStabilized.getMessage(), + ResourceModel.TYPE_NAME, TEST_APP_NAME)); + } + + private ProgressEvent invokeHandleRequest(ResourceHandlerRequest request) { + final DeleteHandler handler = new DeleteHandler(); + return handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + } +} diff --git a/aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/ListHandlerTest.java b/aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/ListHandlerTest.java new file mode 100644 index 0000000..2912fca --- /dev/null +++ b/aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/ListHandlerTest.java @@ -0,0 +1,177 @@ +package software.amazon.sagemaker.app; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsErrorDetails; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.AppDetails; +import software.amazon.awssdk.services.sagemaker.model.AppStatus; +import software.amazon.awssdk.services.sagemaker.model.ListAppsRequest; +import software.amazon.awssdk.services.sagemaker.model.ListAppsResponse; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.cloudformation.exceptions.CfnServiceInternalErrorException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class ListHandlerTest extends software.amazon.sagemaker.app.AbstractTestBase { + + private static final String OPERATION = "SageMaker::ListApps"; + public static final String TEST_TOKEN = "testToken"; + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + SageMakerClient sdkClient; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + sdkClient = mock(SageMakerClient.class); + proxyClient = MOCK_PROXY(proxy, sdkClient); + } + + @Test + public void testListHandler_SimpleSuccess() { + final AppDetails app = AppDetails.builder() + .appName(TEST_APP_NAME) + .appType(TEST_APP_TYPE) + .userProfileName(TEST_USER_PROFILE_NAME) + .domainId(TEST_DOMAIN_ID) + .status(AppStatus.IN_SERVICE) + .build(); + + final ListAppsResponse listResponse = ListAppsResponse.builder() + .apps(app) + .nextToken(TEST_TOKEN) + .build(); + + when(proxyClient.client().listApps(any(ListAppsRequest.class))) + .thenReturn(listResponse); + + final ResourceModel expectedResourceModel = ResourceModel.builder() + .appName(TEST_APP_NAME) + .appType(TEST_APP_TYPE) + .userProfileName(TEST_USER_PROFILE_NAME) + .domainId(TEST_DOMAIN_ID) + .build(); + + List expectedModels = new ArrayList(); + expectedModels.add(expectedResourceModel); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + final ProgressEvent response = invokeHandleRequest(request); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isNull(); + assertThat(response.getResourceModels()).isEqualTo(expectedModels); + assertThat(response.getNextToken()).isEqualTo(TEST_TOKEN); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testListHandler_SimpleSuccess_NoAppsExist() { + final ListAppsResponse listResponse = ListAppsResponse.builder() + .apps(Collections.emptyList()) + .nextToken(null) + .build(); + + when(proxyClient.client().listApps(any(ListAppsRequest.class))) + .thenReturn(listResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + final ProgressEvent response = invokeHandleRequest(request); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isNull(); + assertThat(response.getResourceModels()).isEqualTo(Collections.emptyList()); + assertThat(response.getNextToken()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testListHandler_SimpleSuccess_DeletedApp() { + final AppDetails app = AppDetails.builder() + .status(AppStatus.DELETED) + .build(); + + final ListAppsResponse listResponse = ListAppsResponse.builder() + .apps(app) + .nextToken(TEST_TOKEN) + .build(); + + when(proxyClient.client().listApps(any(ListAppsRequest.class))) + .thenReturn(listResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + final ProgressEvent response = invokeHandleRequest(request); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isNull(); + assertThat(response.getResourceModels()).isEqualTo(Collections.emptyList()); + assertThat(response.getNextToken()).isEqualTo(TEST_TOKEN); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + + @Test + public void testListHandler_ServiceInternalException() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("InternalError").errorMessage(OPERATION).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + when(proxyClient.client().listApps(any(ListAppsRequest.class))) + .thenThrow(ex); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows(CfnServiceInternalErrorException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.ServiceInternalError.getMessage(), + OPERATION)); + } + + private ProgressEvent invokeHandleRequest(ResourceHandlerRequest request) { + final ListHandler handler = new ListHandler(); + return handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + } +} \ No newline at end of file diff --git a/aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/ReadHandlerTest.java b/aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/ReadHandlerTest.java new file mode 100644 index 0000000..3a01087 --- /dev/null +++ b/aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/ReadHandlerTest.java @@ -0,0 +1,183 @@ +package software.amazon.sagemaker.app; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.AppStatus; +import software.amazon.awssdk.services.sagemaker.model.DescribeAppRequest; +import software.amazon.awssdk.services.sagemaker.model.DescribeAppResponse; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.awssdk.services.sagemaker.model.ResourceSpec; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class ReadHandlerTest extends software.amazon.sagemaker.app.AbstractTestBase { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + SageMakerClient sdkClient; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + sdkClient = mock(SageMakerClient.class); + proxyClient = MOCK_PROXY(proxy, sdkClient); + } + + @Test + public void testReadHandler_SimpleSuccess() { + final ResourceSpec resourceSpec = ResourceSpec.builder() + .instanceType(TEST_INSTANCE_TYPE) + .sageMakerImageArn(TEST_IMAGE_ARN) + .sageMakerImageVersionArn(TEST_IMAGE_VERSION_ARN) + .build(); + + final DescribeAppResponse describeResponse = DescribeAppResponse.builder() + .appArn(TEST_APP_ARN) + .appName(TEST_APP_NAME) + .appType(TEST_APP_TYPE) + .domainId(TEST_DOMAIN_ID) + .userProfileName(TEST_USER_PROFILE_NAME) + .resourceSpec(resourceSpec) + .status(AppStatus.IN_SERVICE) + .build(); + + when(proxyClient.client().describeApp(any(DescribeAppRequest.class))) + .thenReturn(describeResponse); + + final software.amazon.sagemaker.app.ResourceSpec expectedResourceSpec = + software.amazon.sagemaker.app.ResourceSpec.builder() + .instanceType(TEST_INSTANCE_TYPE) + .sageMakerImageArn(TEST_IMAGE_ARN) + .sageMakerImageVersionArn(TEST_IMAGE_VERSION_ARN) + .build(); + + final ResourceModel expectedResourceModel = ResourceModel.builder() + .appArn(TEST_APP_ARN) + .appName(TEST_APP_NAME) + .appType(TEST_APP_TYPE) + .domainId(TEST_DOMAIN_ID) + .userProfileName(TEST_USER_PROFILE_NAME) + .resourceSpec(expectedResourceSpec) + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + final ProgressEvent response = invokeHandleRequest(request); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isEqualTo(expectedResourceModel); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + verify(proxyClient.client()).describeApp(any(DescribeAppRequest.class)); + } + + @Test + public void testReadHandler_ResourceNotFoundException_DeletedApp() { + final DescribeAppResponse describeResponse = DescribeAppResponse.builder() + .status(AppStatus.DELETED) + .build(); + + when(proxyClient.client().describeApp(any(DescribeAppRequest.class))) + .thenReturn(describeResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows( CfnNotFoundException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.NotFound.getMessage(), + ResourceModel.TYPE_NAME, TEST_APP_NAME)); + } + + @Test + public void testReadHandler_ServiceInternalException() { + final AwsServiceException serviceInternalException = SageMakerException.builder() + .message("test error message") + .statusCode(500) + .build(); + + when(proxyClient.client().describeApp(any(DescribeAppRequest.class))) + .thenThrow(serviceInternalException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows( CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.READ)); + } + + @Test + public void testReadHandler_ScheduleDoesNotExist_Fails() { + final AwsServiceException resourceNotFoundException = AwsServiceException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(400) + .build(); + + when(proxyClient.client().describeApp(any(DescribeAppRequest.class))) + .thenThrow(resourceNotFoundException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows( CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.READ)); + } + + @Test + public void testReadHandler_ResourceNotFoundException() { + when(proxyClient.client().describeApp(any(DescribeAppRequest.class))) + .thenThrow(ResourceNotFoundException.class); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows(CfnNotFoundException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.NotFound.getMessage(), + ResourceModel.TYPE_NAME, TEST_APP_NAME)); + } + + private ProgressEvent invokeHandleRequest(ResourceHandlerRequest request) { + final ReadHandler handler = new ReadHandler(); + return handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + } +} \ No newline at end of file diff --git a/aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/TranslatorTest.java b/aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/TranslatorTest.java new file mode 100644 index 0000000..dedf1c6 --- /dev/null +++ b/aws-sagemaker-app/src/test/java/software/amazon/sagemaker/app/TranslatorTest.java @@ -0,0 +1,167 @@ +package software.amazon.sagemaker.app; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.awscore.exception.AwsErrorDetails; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.model.ResourceLimitExceededException; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.cloudformation.exceptions.CfnAccessDeniedException; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnInvalidRequestException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.exceptions.CfnServiceInternalErrorException; +import software.amazon.cloudformation.exceptions.CfnServiceLimitExceededException; +import software.amazon.cloudformation.exceptions.CfnThrottlingException; +import software.amazon.cloudformation.proxy.HandlerErrorCode; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class TranslatorTest { + + public static final String TEST_OPERATION = "someOperation"; + public static final String RESOURCE_TYPE = "someResource"; + public static final String RESOURCE_NAME = "someType"; + public static final String ERROR_MESSAGE = "someError"; + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_UnauthorizedOperation() { + AwsErrorDetails errorDetails = + AwsErrorDetails.builder().errorCode("UnauthorizedOperation").errorMessage(ERROR_MESSAGE).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnAccessDeniedException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.AccessDenied.getMessage(), + ERROR_MESSAGE)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_InvalidParameter() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("InvalidParameter").build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnInvalidRequestException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_InvalidParameterValue() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("InvalidParameterValue").build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnInvalidRequestException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ValidationError() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("ValidationError").build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnInvalidRequestException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ValidationException() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("ValidationException").build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnInvalidRequestException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_InternalError() { + AwsErrorDetails errorDetails = + AwsErrorDetails.builder().errorCode("InternalError").errorMessage(ERROR_MESSAGE).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnServiceInternalErrorException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.ServiceInternalError.getMessage(), + ERROR_MESSAGE)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ServiceUnavailable() { + AwsErrorDetails errorDetails = + AwsErrorDetails.builder().errorCode("ServiceUnavailable").errorMessage(ERROR_MESSAGE).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnServiceInternalErrorException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.ServiceInternalError.getMessage(), + ERROR_MESSAGE)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ResourceLimitExceeded() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("ResourceLimitExceeded").build(); + AwsServiceException ex = ResourceLimitExceededException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnServiceLimitExceededException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ResourceNotFound() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("ResourceNotFound").build(); + AwsServiceException ex = ResourceNotFoundException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnNotFoundException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ThrottlingException() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("ThrottlingException").errorMessage(ERROR_MESSAGE).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnThrottlingException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.Throttling.getMessage(), + ERROR_MESSAGE)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_UnknownException() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("Unknown").errorMessage(ERROR_MESSAGE).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnGeneralServiceException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + ERROR_MESSAGE)); + } + + @Test + public void testGetHandlerError_NoErrorCode() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnGeneralServiceException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + TEST_OPERATION)); + } + + @Test + public void testGetHandlerError_NoErrorDetails() { + AwsServiceException ex = SageMakerException.builder().build(); + + Exception exception = assertThrows(CfnGeneralServiceException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + TEST_OPERATION)); + } +} \ No newline at end of file diff --git a/aws-sagemaker-app/template.yml b/aws-sagemaker-app/template.yml new file mode 100644 index 0000000..4c30df6 --- /dev/null +++ b/aws-sagemaker-app/template.yml @@ -0,0 +1,24 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Description: AWS SAM template for the AWS::SageMaker::App resource type + +Globals: + Function: + Timeout: 180 # docker start-up times can be long for SAM CLI + MemorySize: 256 + +Resources: + TypeFunction: + Type: AWS::Serverless::Function + Properties: + Handler: software.amazon.sagemaker.app.HandlerWrapper::handleRequest + Runtime: java8 + CodeUri: ./target/aws-sagemaker-app-handler-1.0-SNAPSHOT.jar + + TestEntrypoint: + Type: AWS::Serverless::Function + Properties: + Handler: software.amazon.sagemaker.app.HandlerWrapper::testEntrypoint + Runtime: java8 + CodeUri: ./target/aws-sagemaker-app-handler-1.0-SNAPSHOT.jar + diff --git a/aws-sagemaker-appimageconfig/.rpdk-config b/aws-sagemaker-appimageconfig/.rpdk-config new file mode 100644 index 0000000..957e2a5 --- /dev/null +++ b/aws-sagemaker-appimageconfig/.rpdk-config @@ -0,0 +1,17 @@ +{ + "typeName": "AWS::SageMaker::AppImageConfig", + "language": "java", + "runtime": "java8", + "entrypoint": "software.amazon.sagemaker.appimageconfig.HandlerWrapper::handleRequest", + "testEntrypoint": "software.amazon.sagemaker.appimageconfig.HandlerWrapper::testEntrypoint", + "settings": { + "namespace": [ + "software", + "amazon", + "sagemaker", + "appimageconfig" + ], + "codegen_template_path": "default", + "protocolVersion": "2.0.0" + } +} diff --git a/aws-sagemaker-appimageconfig/README.md b/aws-sagemaker-appimageconfig/README.md new file mode 100644 index 0000000..b7e500f --- /dev/null +++ b/aws-sagemaker-appimageconfig/README.md @@ -0,0 +1,12 @@ +# AWS::SageMaker::AppImageConfig + +Congratulations on starting development! Next steps: + +1. Write the JSON schema describing your resource, `aws-sagemaker-appimageconfig.json` +1. Implement your resource handlers. + +The RPDK will automatically generate the correct resource model from the schema whenever the project is built via Maven. You can also do this manually with the following command: `cfn generate`. + +> Please don't modify files under `target/generated-sources/rpdk`, as they will be automatically overwritten. + +The code uses [Lombok](https://projectlombok.org/), and [you may have to install IDE integrations](https://projectlombok.org/setup/overview) to enable auto-complete for Lombok-annotated classes. diff --git a/aws-sagemaker-appimageconfig/aws-sagemaker-appimageconfig.json b/aws-sagemaker-appimageconfig/aws-sagemaker-appimageconfig.json new file mode 100644 index 0000000..8c3466c --- /dev/null +++ b/aws-sagemaker-appimageconfig/aws-sagemaker-appimageconfig.json @@ -0,0 +1,172 @@ +{ + "typeName": "AWS::SageMaker::AppImageConfig", + "description": "Resource Type definition for AWS::SageMaker::AppImageConfig", + "additionalProperties": false, + "properties": { + "AppImageConfigArn": { + "type": "string", + "description": "The Amazon Resource Name (ARN) of the AppImageConfig.", + "minLength": 1, + "maxLength": 256, + "pattern": "arn:aws[a-z\\-]*:sagemaker:[a-z0-9\\-]*:[0-9]{12}:app-image-config/.*" + }, + "AppImageConfigName": { + "type": "string", + "description": "The Name of the AppImageConfig.", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-zA-Z0-9](-*[a-zA-Z0-9]){0,62}" + }, + "KernelGatewayImageConfig": { + "$ref": "#/definitions/KernelGatewayImageConfig", + "description": "The KernelGatewayImageConfig." + }, + "Tags": { + "type": "array", + "description": "A list of tags to apply to the AppImageConfig.", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Tag" + }, + "minItems": 0, + "maxItems": 50 + } + }, + "definitions": { + "KernelGatewayImageConfig": { + "type": "object", + "description": "The configuration for the file system and kernels in a SageMaker image running as a KernelGateway app.", + "additionalProperties": false, + "properties": { + "FileSystemConfig": { + "$ref": "#/definitions/FileSystemConfig", + "description": "The Amazon Elastic File System (EFS) storage configuration for a SageMaker image." + }, + "KernelSpecs": { + "type": "array", + "description": "The specification of the Jupyter kernels in the image.", + "minItems": 1, + "maxItems": 1, + "items": { + "$ref": "#/definitions/KernelSpec" + } + } + }, + "required": [ + "KernelSpecs" + ] + }, + "FileSystemConfig": { + "type": "object", + "description": "The Amazon Elastic File System (EFS) storage configuration for a SageMaker image.", + "additionalProperties": false, + "properties": { + "DefaultGid": { + "type": "integer", + "description": "The default POSIX group ID (GID). If not specified, defaults to 100.", + "minimum" : 0, + "maximum" : 65535 + }, + "DefaultUid": { + "type": "integer", + "description": "The default POSIX user ID (UID). If not specified, defaults to 1000.", + "minimum" : 0, + "maximum" : 65535 + }, + "MountPath": { + "type": "string", + "description": "The path within the image to mount the user's EFS home directory. The directory should be empty. If not specified, defaults to /home/sagemaker-user.", + "minLength": 1, + "maxLength": 1024, + "pattern": "^\/.*" + } + } + }, + "KernelSpec": { + "type": "object", + "additionalProperties": false, + "properties": { + "DisplayName": { + "type": "string", + "description": "The display name of the kernel.", + "minLength": 1, + "maxLength": 1024 + }, + "Name": { + "type": "string", + "description": "The name of the kernel.", + "minLength": 1, + "maxLength": 1024 + } + }, + "required": [ + "Name" + ] + }, + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "Key": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "Key", + "Value" + ] + } + }, + "required": [ + "AppImageConfigName" + ], + "createOnlyProperties": [ + "/properties/AppImageConfigName", + "/properties/Tags" + ], + "writeOnlyProperties": [ + "/properties/Tags" + ], + "readOnlyProperties": [ + "/properties/AppImageConfigArn" + ], + "primaryIdentifier": [ + "/properties/AppImageConfigName" + ], + "handlers": { + "create": { + "permissions": [ + "sagemaker:CreateAppImageConfig", + "sagemaker:DescribeAppImageConfig" + ] + }, + "read": { + "permissions": [ + "sagemaker:DescribeAppImageConfig" + ] + }, + "update": { + "permissions": [ + "sagemaker:UpdateAppImageConfig", + "sagemaker:DescribeAppImageConfig" + ] + }, + "delete": { + "permissions": [ + "sagemaker:DeleteAppImageConfig" + ] + }, + "list": { + "permissions": [ + "sagemaker:ListAppImageConfigs" + ] + } + } +} diff --git a/aws-sagemaker-appimageconfig/docs/README.md b/aws-sagemaker-appimageconfig/docs/README.md new file mode 100644 index 0000000..19724b3 --- /dev/null +++ b/aws-sagemaker-appimageconfig/docs/README.md @@ -0,0 +1,86 @@ +# AWS::SageMaker::AppImageConfig + +Resource Type definition for AWS::SageMaker::AppImageConfig + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Type" : "AWS::SageMaker::AppImageConfig",
+    "Properties" : {
+        "AppImageConfigName" : String,
+        "KernelGatewayImageConfig" : KernelGatewayImageConfig,
+        "Tags" : [ Tag, ... ]
+    }
+}
+
+ +### YAML + +
+Type: AWS::SageMaker::AppImageConfig
+Properties:
+    AppImageConfigName: String
+    KernelGatewayImageConfig: KernelGatewayImageConfig
+    Tags: 
+      - Tag
+
+ +## Properties + +#### AppImageConfigName + +The Name of the AppImageConfig. + +_Required_: Yes + +_Type_: String + +_Minimum_: 1 + +_Maximum_: 63 + +_Pattern_: ^[a-zA-Z0-9](-*[a-zA-Z0-9]){0,62} + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### KernelGatewayImageConfig + +The configuration for the file system and kernels in a SageMaker image running as a KernelGateway app. + +_Required_: No + +_Type_: KernelGatewayImageConfig + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Tags + +A list of tags to apply to the AppImageConfig. + +_Required_: No + +_Type_: List of Tag + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +## Return Values + +### Ref + +When you pass the logical ID of this resource to the intrinsic `Ref` function, Ref returns the AppImageConfigName. + +### Fn::GetAtt + +The `Fn::GetAtt` intrinsic function returns a value for a specified attribute of this type. The following are the available attributes and sample return values. + +For more information about using the `Fn::GetAtt` intrinsic function, see [Fn::GetAtt](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getatt.html). + +#### AppImageConfigArn + +The Amazon Resource Name (ARN) of the AppImageConfig. + diff --git a/aws-sagemaker-appimageconfig/docs/filesystemconfig.md b/aws-sagemaker-appimageconfig/docs/filesystemconfig.md new file mode 100644 index 0000000..cfd4a4e --- /dev/null +++ b/aws-sagemaker-appimageconfig/docs/filesystemconfig.md @@ -0,0 +1,64 @@ +# AWS::SageMaker::AppImageConfig FileSystemConfig + +The Amazon Elastic File System (EFS) storage configuration for a SageMaker image. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "DefaultGid" : Integer,
+    "DefaultUid" : Integer,
+    "MountPath" : String
+}
+
+ +### YAML + +
+DefaultGid: Integer
+DefaultUid: Integer
+MountPath: String
+
+ +## Properties + +#### DefaultGid + +The default POSIX group ID (GID). If not specified, defaults to 100. + +_Required_: No + +_Type_: Integer + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### DefaultUid + +The default POSIX user ID (UID). If not specified, defaults to 1000. + +_Required_: No + +_Type_: Integer + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### MountPath + +The path within the image to mount the user's EFS home directory. The directory should be empty. If not specified, defaults to /home/sagemaker-user. + +_Required_: No + +_Type_: String + +_Minimum_: 1 + +_Maximum_: 1024 + +_Pattern_: ^/.* + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-sagemaker-appimageconfig/docs/kernelgatewayimageconfig.md b/aws-sagemaker-appimageconfig/docs/kernelgatewayimageconfig.md new file mode 100644 index 0000000..64fd459 --- /dev/null +++ b/aws-sagemaker-appimageconfig/docs/kernelgatewayimageconfig.md @@ -0,0 +1,47 @@ +# AWS::SageMaker::AppImageConfig KernelGatewayImageConfig + +The configuration for the file system and kernels in a SageMaker image running as a KernelGateway app. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "FileSystemConfig" : FileSystemConfig,
+    "KernelSpecs" : [ KernelSpec, ... ]
+}
+
+ +### YAML + +
+FileSystemConfig: FileSystemConfig
+KernelSpecs: 
+      - KernelSpec
+
+ +## Properties + +#### FileSystemConfig + +The Amazon Elastic File System (EFS) storage configuration for a SageMaker image. + +_Required_: No + +_Type_: FileSystemConfig + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### KernelSpecs + +The specification of the Jupyter kernels in the image. + +_Required_: Yes + +_Type_: List of KernelSpec + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-sagemaker-appimageconfig/docs/kernelspec.md b/aws-sagemaker-appimageconfig/docs/kernelspec.md new file mode 100644 index 0000000..6959417 --- /dev/null +++ b/aws-sagemaker-appimageconfig/docs/kernelspec.md @@ -0,0 +1,52 @@ +# AWS::SageMaker::AppImageConfig KernelSpec + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "DisplayName" : String,
+    "Name" : String
+}
+
+ +### YAML + +
+DisplayName: String
+Name: String
+
+ +## Properties + +#### DisplayName + +The display name of the kernel. + +_Required_: No + +_Type_: String + +_Minimum_: 1 + +_Maximum_: 1024 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Name + +The name of the kernel. + +_Required_: Yes + +_Type_: String + +_Minimum_: 1 + +_Maximum_: 1024 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-sagemaker-appimageconfig/docs/tag.md b/aws-sagemaker-appimageconfig/docs/tag.md new file mode 100644 index 0000000..16385f1 --- /dev/null +++ b/aws-sagemaker-appimageconfig/docs/tag.md @@ -0,0 +1,48 @@ +# AWS::SageMaker::AppImageConfig Tag + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Value" : String,
+    "Key" : String
+}
+
+ +### YAML + +
+Value: String
+Key: String
+
+ +## Properties + +#### Value + +_Required_: Yes + +_Type_: String + +_Minimum_: 1 + +_Maximum_: 128 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Key + +_Required_: Yes + +_Type_: String + +_Minimum_: 1 + +_Maximum_: 128 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-sagemaker-appimageconfig/lombok.config b/aws-sagemaker-appimageconfig/lombok.config new file mode 100644 index 0000000..7a21e88 --- /dev/null +++ b/aws-sagemaker-appimageconfig/lombok.config @@ -0,0 +1 @@ +lombok.addLombokGeneratedAnnotation = true diff --git a/aws-sagemaker-appimageconfig/pom.xml b/aws-sagemaker-appimageconfig/pom.xml new file mode 100644 index 0000000..947d96e --- /dev/null +++ b/aws-sagemaker-appimageconfig/pom.xml @@ -0,0 +1,210 @@ + + + 4.0.0 + + software.amazon.sagemaker.appimageconfig + aws-sagemaker-appimageconfig-handler + aws-sagemaker-appimageconfig-handler + 1.0-SNAPSHOT + jar + + + 1.8 + 1.8 + UTF-8 + UTF-8 + + + + + + software.amazon.awssdk + sagemaker + 2.15.41 + + + + software.amazon.cloudformation + aws-cloudformation-rpdk-java-plugin + [2.0.0,3.0.0) + + + + org.projectlombok + lombok + 1.18.4 + provided + + + + + org.assertj + assertj-core + 3.12.2 + test + + + + org.junit.jupiter + junit-jupiter + 5.5.0-M1 + test + + + + org.mockito + mockito-core + 3.6.0 + test + + + + org.mockito + mockito-junit-jupiter + 3.6.0 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + -Xlint:all,-options,-processing + -Werror + + + + + org.apache.maven.plugins + maven-shade-plugin + 2.3 + + false + + + + package + + shade + + + + + + org.codehaus.mojo + exec-maven-plugin + 1.6.0 + + + generate + generate-sources + + exec + + + cfn + generate + ${project.basedir} + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.0.0 + + + add-source + generate-sources + + add-source + + + + ${project.basedir}/target/generated-sources/rpdk + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 2.4 + + + maven-surefire-plugin + 3.0.0-M3 + + + org.jacoco + jacoco-maven-plugin + 0.8.4 + + + **/BaseConfiguration* + **/BaseHandler* + **/HandlerWrapper* + **/ResourceModel* + + + + + + prepare-agent + + + + report + test + + report + + + + jacoco-check + + check + + + + + PACKAGE + + + BRANCH + COVEREDRATIO + 0.8 + + + INSTRUCTION + COVEREDRATIO + 0.8 + + + + + + + + + + + + ${project.basedir} + + aws-sagemaker-appimageconfig.json + + + + + diff --git a/aws-sagemaker-appimageconfig/resource-role.yaml b/aws-sagemaker-appimageconfig/resource-role.yaml new file mode 100644 index 0000000..8e133a2 --- /dev/null +++ b/aws-sagemaker-appimageconfig/resource-role.yaml @@ -0,0 +1,35 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: > + This CloudFormation template creates a role assumed by CloudFormation + during CRUDL operations to mutate resources on behalf of the customer. + +Resources: + ExecutionRole: + Type: AWS::IAM::Role + Properties: + MaxSessionDuration: 8400 + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: resources.cloudformation.amazonaws.com + Action: sts:AssumeRole + Path: "/" + Policies: + - PolicyName: ResourceTypePolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - "sagemaker:CreateAppImageConfig" + - "sagemaker:DeleteAppImageConfig" + - "sagemaker:DescribeAppImageConfig" + - "sagemaker:ListAppImageConfigs" + - "sagemaker:UpdateAppImageConfig" + Resource: "*" +Outputs: + ExecutionRoleArn: + Value: + Fn::GetAtt: ExecutionRole.Arn diff --git a/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/BaseHandlerStd.java b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/BaseHandlerStd.java new file mode 100644 index 0000000..e1037e3 --- /dev/null +++ b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/BaseHandlerStd.java @@ -0,0 +1,36 @@ +package software.amazon.sagemaker.appimageconfig; + +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +/** + * Common handler function definition for Create/Read/Update/Delete/List handlers + */ +public abstract class BaseHandlerStd extends BaseHandler { + + @Override + public final ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final Logger logger) { + return handleRequest( + proxy, + request, + callbackContext != null ? callbackContext : new CallbackContext(), + proxy.newProxy(ClientBuilder::getClient), + logger + ); + } + + protected abstract ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger); +} \ No newline at end of file diff --git a/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/CallbackContext.java b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/CallbackContext.java new file mode 100644 index 0000000..9332345 --- /dev/null +++ b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/CallbackContext.java @@ -0,0 +1,10 @@ +package software.amazon.sagemaker.appimageconfig; + +import software.amazon.cloudformation.proxy.StdCallbackContext; + +@lombok.Getter +@lombok.Setter +@lombok.ToString +@lombok.EqualsAndHashCode(callSuper = true) +public class CallbackContext extends StdCallbackContext { +} diff --git a/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/ClientBuilder.java b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/ClientBuilder.java new file mode 100644 index 0000000..e6ec901 --- /dev/null +++ b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/ClientBuilder.java @@ -0,0 +1,9 @@ +package software.amazon.sagemaker.appimageconfig; + +import software.amazon.awssdk.services.sagemaker.SageMakerClient; + +public class ClientBuilder { + public static SageMakerClient getClient() { + return SageMakerClient.builder().build(); + } +} diff --git a/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/Configuration.java b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/Configuration.java new file mode 100644 index 0000000..76b540a --- /dev/null +++ b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/Configuration.java @@ -0,0 +1,8 @@ +package software.amazon.sagemaker.appimageconfig; + +class Configuration extends BaseConfiguration { + + public Configuration() { + super("aws-sagemaker-appimageconfig.json"); + } +} diff --git a/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/CreateHandler.java b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/CreateHandler.java new file mode 100644 index 0000000..3d5872c --- /dev/null +++ b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/CreateHandler.java @@ -0,0 +1,80 @@ +package software.amazon.sagemaker.appimageconfig; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.CreateAppImageConfigRequest; +import software.amazon.awssdk.services.sagemaker.model.CreateAppImageConfigResponse; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnInvalidRequestException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class CreateHandler extends BaseHandlerStd { + + private static final String OPERATION = "AWS-SageMaker-AppImageConfig::Create"; + private static final String READ_ONLY_PROPERTY_ERROR_MESSAGE = "The following property '%s' is not allowed to configured."; + + private Logger logger; + + protected ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + + final ResourceModel model = request.getDesiredResourceState(); + + // read only properties are not allowed to be set by the user during creation. + // https://github.com/aws-cloudformation/aws-cloudformation-resource-schema/issues/102 + if (model.getAppImageConfigArn() != null) { + throw new CfnInvalidRequestException(String.format(READ_ONLY_PROPERTY_ERROR_MESSAGE, "AppImageConfig")); + } + + return ProgressEvent.progress(model, callbackContext) + .then(progress -> + proxy.initiate(OPERATION, proxyClient, model, callbackContext) + .translateToServiceRequest(TranslatorForRequest::translateToCreateRequest) + .makeServiceCall(this::createResource) + .done(createResponse -> constructResourceModelFromResponse(model, createResponse)) + ); + } + + /** + * Client invocation of the create request through the proxyClient + * + * @param createRequest aws service create resource request + * @param proxyClient aws service client to make the call + * @return create resource response + */ + private CreateAppImageConfigResponse createResource( + final CreateAppImageConfigRequest createRequest, + final ProxyClient proxyClient) { + CreateAppImageConfigResponse response = null; + try { + response = proxyClient.injectCredentialsAndInvokeV2( + createRequest, proxyClient.client()::createAppImageConfig); + } catch (final AwsServiceException e) { + Translator.throwCfnException(Action.CREATE.toString(), ResourceModel.TYPE_NAME, createRequest.appImageConfigName(), e); + } + return response; + } + + /** + * Build the Progress Event object from the create response. + * + * @param model resource model + * @param awsResponse aws service create resource response + * @return progressEvent indicating success + */ + private ProgressEvent constructResourceModelFromResponse( + final ResourceModel model, final CreateAppImageConfigResponse awsResponse) { + model.setAppImageConfigArn(awsResponse.appImageConfigArn()); + return ProgressEvent.defaultSuccessHandler(model); + } +} diff --git a/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/DeleteHandler.java b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/DeleteHandler.java new file mode 100644 index 0000000..1301143 --- /dev/null +++ b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/DeleteHandler.java @@ -0,0 +1,65 @@ +package software.amazon.sagemaker.appimageconfig; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.DeleteAppImageConfigRequest; +import software.amazon.awssdk.services.sagemaker.model.DeleteAppImageConfigResponse; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class DeleteHandler extends BaseHandlerStd { + + private static final String OPERATION = "AWS-SageMaker-AppImageConfig::Delete"; + + private Logger logger; + + protected ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + + final ResourceModel model = request.getDesiredResourceState(); + + return ProgressEvent.progress(model, callbackContext) + .then(progress -> + proxy.initiate(OPERATION, proxyClient, model, callbackContext) + .translateToServiceRequest(TranslatorForRequest::translateToDeleteRequest) + .makeServiceCall(this::deleteResource) + .done(awsResponse -> ProgressEvent.builder() + .status(OperationStatus.SUCCESS) + .build())); + } + + /** + * Client invocation of the delete request through the proxyClient. + * + * @param awsRequest aws service delete resource request + * @param proxyClient aws service client to make the call + * @return delete resource response + */ + private DeleteAppImageConfigResponse deleteResource( + final DeleteAppImageConfigRequest awsRequest, + final ProxyClient proxyClient) { + + DeleteAppImageConfigResponse response = null; + try { + response = proxyClient.injectCredentialsAndInvokeV2(awsRequest, proxyClient.client()::deleteAppImageConfig); + } catch (final AwsServiceException e) { + Translator.throwCfnException(Action.DELETE.toString(), ResourceModel.TYPE_NAME, + awsRequest.appImageConfigName(), e); + } + + return response; + } +} diff --git a/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/ListHandler.java b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/ListHandler.java new file mode 100644 index 0000000..a625f47 --- /dev/null +++ b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/ListHandler.java @@ -0,0 +1,74 @@ +package software.amazon.sagemaker.appimageconfig; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.ListAppImageConfigsRequest; +import software.amazon.awssdk.services.sagemaker.model.ListAppImageConfigsResponse; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class ListHandler extends BaseHandlerStd { + + private static final String OPERATION = "AWS-SageMaker-AppImageConfig::List"; + + private Logger logger; + + @Override + public ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + + final ResourceModel model = request.getDesiredResourceState(); + + return proxy.initiate(OPERATION, proxyClient, model, callbackContext) + .translateToServiceRequest(resourceModel -> TranslatorForRequest.translateToListRequest(request.getNextToken())) + .makeServiceCall(this::listResources) + .done(this::constructResourceModelFromResponse); + } + + /** + * Client invocation of the list request through the proxyClient. + * + * @param awsRequest aws service list resource request + * @param proxyClient aws service client used for making calls + * @return list resource response + */ + private ListAppImageConfigsResponse listResources( + final ListAppImageConfigsRequest awsRequest, + final ProxyClient proxyClient) { + + ListAppImageConfigsResponse response = null; + try { + response = proxyClient.injectCredentialsAndInvokeV2(awsRequest, proxyClient.client()::listAppImageConfigs); + } catch (final AwsServiceException e) { + Translator.throwCfnException(Action.LIST.toString(), ResourceModel.TYPE_NAME, null, e); + } + + return response; + } + + /** + * Build the Progress Event object from the list response. + * + * @param listResponse the aws service list resource response + * @return progressEvent indicating success + */ + private ProgressEvent constructResourceModelFromResponse( + final ListAppImageConfigsResponse listResponse) { + return ProgressEvent.builder() + .nextToken(listResponse.nextToken()) + .resourceModels(TranslatorForResponse.translateFromListResponse(listResponse)) + .status(OperationStatus.SUCCESS) + .build(); + } +} diff --git a/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/ReadHandler.java b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/ReadHandler.java new file mode 100644 index 0000000..33c59a8 --- /dev/null +++ b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/ReadHandler.java @@ -0,0 +1,69 @@ +package software.amazon.sagemaker.appimageconfig; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.DescribeAppImageConfigRequest; +import software.amazon.awssdk.services.sagemaker.model.DescribeAppImageConfigResponse; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class ReadHandler extends BaseHandlerStd { + + private static final String OPERATION = "AWS-SageMaker-AppImageConfig::Read"; + + private Logger logger; + + @Override + public ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + final ResourceModel model = request.getDesiredResourceState(); + + return proxy.initiate(OPERATION, proxyClient, model, callbackContext) + .translateToServiceRequest(TranslatorForRequest::translateToReadRequest) + .makeServiceCall(this::readResource) + .done(this::constructResourceModelFromResponse); + } + + /** + * Client invocation of the read request through the proxyClient + * + * @param awsRequest aws describe resource request + * @param proxyClient the aws service client to make the call + * @return describe resource response + */ + private DescribeAppImageConfigResponse readResource( + final DescribeAppImageConfigRequest awsRequest, + final ProxyClient proxyClient) { + + DescribeAppImageConfigResponse response = null; + try { + response = proxyClient.injectCredentialsAndInvokeV2(awsRequest, proxyClient.client()::describeAppImageConfig); + } catch (final AwsServiceException e) { + Translator.throwCfnException(Action.READ.toString(), ResourceModel.TYPE_NAME, + awsRequest.appImageConfigName(), e); + } + + return response; + } + + /** + * Build the Progress Event object from the read response. + * + * @param awsResponse aws service describe resource response + * @return progressEvent indicating success + */ + private ProgressEvent constructResourceModelFromResponse( + final DescribeAppImageConfigResponse awsResponse) { + return ProgressEvent.defaultSuccessHandler(TranslatorForResponse.translateFromReadResponse(awsResponse)); + } +} diff --git a/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/Translator.java b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/Translator.java new file mode 100644 index 0000000..087668d --- /dev/null +++ b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/Translator.java @@ -0,0 +1,81 @@ +package software.amazon.sagemaker.appimageconfig; + +import org.apache.commons.lang3.StringUtils; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.model.ResourceInUseException; +import software.amazon.awssdk.services.sagemaker.model.ResourceLimitExceededException; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.cloudformation.exceptions.CfnAccessDeniedException; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnInvalidRequestException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.exceptions.CfnServiceInternalErrorException; +import software.amazon.cloudformation.exceptions.CfnServiceLimitExceededException; +import software.amazon.cloudformation.exceptions.CfnThrottlingException; +import software.amazon.cloudformation.exceptions.ResourceAlreadyExistsException; + +import java.util.Collection; +import java.util.Optional; +import java.util.stream.Stream; + +/** + * Contains common methods required by other translators. + */ +public class Translator { + + /** + * Throws a Cfn exception for the corresponding error code of the given exception. + * + * @param operation operation + * @param resourceType resource type + * @param resourceName resource name + * @param e exception + */ + static void throwCfnException( + final String operation, + final String resourceType, + final String resourceName, + final AwsServiceException e + ) { + + if (e instanceof ResourceInUseException) { + throw new ResourceAlreadyExistsException(resourceType, resourceName, e); + } + + if (e instanceof ResourceNotFoundException) { + throw new CfnNotFoundException(resourceType, resourceName, e); + } + + if (e instanceof ResourceLimitExceededException) { + throw new CfnServiceLimitExceededException(resourceType, e.getMessage(), e); + } + + if(e.awsErrorDetails() != null && StringUtils.isNotBlank(e.awsErrorDetails().errorCode())) { + String errorMessage = e.awsErrorDetails().errorMessage(); + switch (e.awsErrorDetails().errorCode()) { + case "UnauthorizedOperation": + throw new CfnAccessDeniedException(errorMessage, e); + case "InvalidParameter": + case "InvalidParameterValue": + case "ValidationError": + case "ValidationException": + throw new CfnInvalidRequestException(errorMessage, e); + case "InternalError": + case "ServiceUnavailable": + throw new CfnServiceInternalErrorException(errorMessage, e); + case "ThrottlingException": + throw new CfnThrottlingException(errorMessage, e); + default: + throw new CfnGeneralServiceException(errorMessage, e); + } + } + + throw new CfnGeneralServiceException(operation, e); + } + + static Stream streamOfOrEmpty(final Collection collection) { + return Optional.ofNullable(collection) + .map(Collection::stream) + .orElseGet(Stream::empty); + } +} \ No newline at end of file diff --git a/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/TranslatorForRequest.java b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/TranslatorForRequest.java new file mode 100644 index 0000000..3a64d9a --- /dev/null +++ b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/TranslatorForRequest.java @@ -0,0 +1,106 @@ +package software.amazon.sagemaker.appimageconfig; + +import software.amazon.awssdk.services.sagemaker.model.CreateAppImageConfigRequest; +import software.amazon.awssdk.services.sagemaker.model.DeleteAppImageConfigRequest; +import software.amazon.awssdk.services.sagemaker.model.DescribeAppImageConfigRequest; +import software.amazon.awssdk.services.sagemaker.model.FileSystemConfig; +import software.amazon.awssdk.services.sagemaker.model.KernelGatewayImageConfig; +import software.amazon.awssdk.services.sagemaker.model.KernelSpec; +import software.amazon.awssdk.services.sagemaker.model.ListAppImageConfigsRequest; +import software.amazon.awssdk.services.sagemaker.model.Tag; +import software.amazon.awssdk.services.sagemaker.model.UpdateAppImageConfigRequest; + +import java.util.stream.Collectors; + +final class TranslatorForRequest { + + private TranslatorForRequest() {} + + /** + * Translates ResourceModel input to an aws sdk create resource request + * + * @param model resource model + * @return aws sdk create resource request + */ + static CreateAppImageConfigRequest translateToCreateRequest(final ResourceModel model) { + return CreateAppImageConfigRequest.builder() + .appImageConfigName(model.getAppImageConfigName()) + .kernelGatewayImageConfig(translateKernelGatewayImageConfig(model.getKernelGatewayImageConfig())) + .tags(Translator.streamOfOrEmpty(model.getTags()) + .map(t -> Tag.builder() + .key(t.getKey()) + .value(t.getValue()) + .build()) + .collect(Collectors.toList()) + ).build(); + } + + /** + * Translates ResourceModel input to an aws sdk read resource request + * + * @param model resource model + * @return aws sdk read resource request + */ + static DescribeAppImageConfigRequest translateToReadRequest(final ResourceModel model) { + return DescribeAppImageConfigRequest.builder() + .appImageConfigName(model.getAppImageConfigName()) + .build(); + } + + /** + * Translates ResourceModel input to an aws sdk delete resource request + * + * @param model resource model + * @return aws sdk delete resource request + */ + static DeleteAppImageConfigRequest translateToDeleteRequest(final ResourceModel model) { + return DeleteAppImageConfigRequest.builder() + .appImageConfigName(model.getAppImageConfigName()) + .build(); + } + + /** + * Translates ResourceModel input to an aws sdk list resource request + * + * @param nextToken token passed to the aws service describe resource request + * @return list resource request + */ + static ListAppImageConfigsRequest translateToListRequest(final String nextToken) { + return ListAppImageConfigsRequest.builder().nextToken(nextToken).build(); + } + + /** + * Translates ResourceModel input to an aws sdk update resource request + * + * @param model resource model + * @return update resource request + */ + static UpdateAppImageConfigRequest translateToUpdateRequest(final ResourceModel model) { + return UpdateAppImageConfigRequest.builder() + .appImageConfigName(model.getAppImageConfigName()) + .kernelGatewayImageConfig(translateKernelGatewayImageConfig(model.getKernelGatewayImageConfig())) + .build(); + } + + private static KernelGatewayImageConfig translateKernelGatewayImageConfig( + software.amazon.sagemaker.appimageconfig.KernelGatewayImageConfig origin) { + if (origin == null) { + return null; + } + + return KernelGatewayImageConfig.builder() + .fileSystemConfig(origin.getFileSystemConfig() == null ? null : + FileSystemConfig.builder() + .defaultGid(origin.getFileSystemConfig().getDefaultGid()) + .defaultUid(origin.getFileSystemConfig().getDefaultUid()) + .mountPath(origin.getFileSystemConfig().getMountPath()) + .build()) + .kernelSpecs(origin.getKernelSpecs().stream() + .map(kernelSpec -> KernelSpec.builder() + .displayName(kernelSpec.getDisplayName()) + .name(kernelSpec.getName()) + .build()) + .collect(Collectors.toList())) + .build(); + } +} \ No newline at end of file diff --git a/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/TranslatorForResponse.java b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/TranslatorForResponse.java new file mode 100644 index 0000000..92572d7 --- /dev/null +++ b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/TranslatorForResponse.java @@ -0,0 +1,62 @@ +package software.amazon.sagemaker.appimageconfig; + +import software.amazon.awssdk.services.sagemaker.model.DescribeAppImageConfigResponse; +import software.amazon.awssdk.services.sagemaker.model.ListAppImageConfigsResponse; + +import java.util.List; +import java.util.stream.Collectors; + +public class TranslatorForResponse { + + private TranslatorForResponse() {} + + /** + * Translates the AWS SDK read response into a native resource model + * + * @param awsResponse the aws service describe resource response + * @return resource model + */ + static ResourceModel translateFromReadResponse(final DescribeAppImageConfigResponse awsResponse) { + return ResourceModel.builder() + .appImageConfigArn(awsResponse.appImageConfigArn()) + .appImageConfigName(awsResponse.appImageConfigName()) + .kernelGatewayImageConfig(translateToKernelGatewayImageConfig(awsResponse.kernelGatewayImageConfig())) + .build(); + } + + /** + * Translates the AWS SDK list response into a native resource model + * + * @param awsResponse the aws service list resource response + * @return list of resource models + */ + static List translateFromListResponse(final ListAppImageConfigsResponse awsResponse) { + return Translator.streamOfOrEmpty(awsResponse.appImageConfigs()) + .map(appImageConfig -> ResourceModel.builder() + .appImageConfigName(appImageConfig.appImageConfigName()) + .build()) + .collect(Collectors.toList()); + } + + private static KernelGatewayImageConfig translateToKernelGatewayImageConfig( + software.amazon.awssdk.services.sagemaker.model.KernelGatewayImageConfig origin) { + if (origin == null) { + return null; + } + + return KernelGatewayImageConfig.builder() + .fileSystemConfig(origin.fileSystemConfig() == null ? null : + FileSystemConfig.builder() + .defaultGid(origin.fileSystemConfig().defaultGid()) + .defaultUid(origin.fileSystemConfig().defaultUid()) + .mountPath(origin.fileSystemConfig().mountPath()) + .build()) + .kernelSpecs(origin.kernelSpecs().stream() + .map(kernelSpec -> KernelSpec.builder() + .displayName(kernelSpec.displayName()) + .name(kernelSpec.name()) + .build()) + .collect(Collectors.toList())) + .build(); + } +} diff --git a/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/UpdateHandler.java b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/UpdateHandler.java new file mode 100644 index 0000000..cf1ebd4 --- /dev/null +++ b/aws-sagemaker-appimageconfig/src/main/java/software/amazon/sagemaker/appimageconfig/UpdateHandler.java @@ -0,0 +1,72 @@ +package software.amazon.sagemaker.appimageconfig; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.UpdateAppImageConfigRequest; +import software.amazon.awssdk.services.sagemaker.model.UpdateAppImageConfigResponse; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class UpdateHandler extends BaseHandlerStd { + + private static final String OPERATION = "AWS-SageMaker-AppImageConfig::Update"; + private Logger logger; + + @Override + protected ProgressEvent handleRequest( + AmazonWebServicesClientProxy proxy, + ResourceHandlerRequest request, + CallbackContext callbackContext, + ProxyClient proxyClient, + Logger logger + ) { + + this.logger = logger; + final ResourceModel model = request.getDesiredResourceState(); + + return ProgressEvent.progress(model, callbackContext) + .then(progress -> + proxy.initiate(OPERATION, proxyClient, model, callbackContext) + .translateToServiceRequest(TranslatorForRequest::translateToUpdateRequest) + .makeServiceCall(this::updateResource) + .done(awsResponse -> constructResourceModelFromResponse(model, awsResponse)) + ); + } + + /** + * Client invocation of the update request through the proxyClient + * + * @param awsRequest aws service update resource request + * @param proxyClient the aws service client to make the call + * @return update resource response + */ + private UpdateAppImageConfigResponse updateResource( + final UpdateAppImageConfigRequest awsRequest, + final ProxyClient proxyClient + ) { + UpdateAppImageConfigResponse response = null; + try { + response = proxyClient.injectCredentialsAndInvokeV2(awsRequest, proxyClient.client()::updateAppImageConfig); + } catch (final AwsServiceException e) { + Translator.throwCfnException(Action.UPDATE.toString(), ResourceModel.TYPE_NAME, awsRequest.appImageConfigName(), e); + } + return response; + } + + /** + * Build the Progress Event object from the update response. + * + * @param model resource model + * @param awsResponse aws service update resource response + * @return progressEvent indicating success + */ + private ProgressEvent constructResourceModelFromResponse( + final ResourceModel model, final UpdateAppImageConfigResponse awsResponse) { + model.setAppImageConfigArn(awsResponse.appImageConfigArn()); + return ProgressEvent.defaultSuccessHandler(model); + } +} diff --git a/aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/AbstractTestBase.java b/aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/AbstractTestBase.java new file mode 100644 index 0000000..5c56e39 --- /dev/null +++ b/aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/AbstractTestBase.java @@ -0,0 +1,51 @@ +package software.amazon.sagemaker.appimageconfig; + +import software.amazon.awssdk.awscore.AwsRequest; +import software.amazon.awssdk.awscore.AwsResponse; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Credentials; +import software.amazon.cloudformation.proxy.LoggerProxy; +import software.amazon.cloudformation.proxy.ProxyClient; + +import java.util.function.Function; + +public class AbstractTestBase { + protected static final String TEST_APP_IMAGE_CONFIG_NAME = "testAppImageConfigName"; + protected static final String TEST_APP_IMAGE_CONFIG_ARN = "testAppImageConfigArn"; + protected static final String TEST_KERNEL_NAME = "testKernel"; + protected static final String TEST_KERNEL_DISPLAY = "testKernelDisplay"; + protected static final String TEST_MOUNT_PATH = "testMountPath"; + protected static final int TEST_DEFAULT_GID = 1; + protected static final int TEST_DEFAULT_UID = 2; + protected static final String TEST_ERROR_MESSAGE = "test error message"; + protected static final Credentials MOCK_CREDENTIALS; + protected static final LoggerProxy logger; + + static { + MOCK_CREDENTIALS = new Credentials("accessKey", "secretKey", "token"); + logger = new LoggerProxy(); + } + static ProxyClient MOCK_PROXY( + final AmazonWebServicesClientProxy proxy, + final SageMakerClient sagemakerClient) { + return new ProxyClient() { + @Override + public ResponseT + injectCredentialsAndInvokeV2(RequestT request, Function requestFunction) { + return proxy.injectCredentialsAndInvokeV2(request, requestFunction); + } + + @Override + public SageMakerClient client() { + return sagemakerClient; + } + }; + } + + protected static ResourceModel getRequestResourceModel() { + return ResourceModel.builder() + .appImageConfigName(TEST_APP_IMAGE_CONFIG_NAME) + .build(); + } +} \ No newline at end of file diff --git a/aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/CreateHandlerTest.java b/aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/CreateHandlerTest.java new file mode 100644 index 0000000..ea0cbcd --- /dev/null +++ b/aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/CreateHandlerTest.java @@ -0,0 +1,207 @@ +package software.amazon.sagemaker.appimageconfig; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsErrorDetails; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.CreateAppImageConfigRequest; +import software.amazon.awssdk.services.sagemaker.model.CreateAppImageConfigResponse; +import software.amazon.awssdk.services.sagemaker.model.ResourceInUseException; +import software.amazon.awssdk.services.sagemaker.model.ResourceLimitExceededException; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnInvalidRequestException; +import software.amazon.cloudformation.exceptions.CfnServiceLimitExceededException; +import software.amazon.cloudformation.exceptions.ResourceAlreadyExistsException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class CreateHandlerTest extends software.amazon.sagemaker.appimageconfig.AbstractTestBase { + + private final ResourceModel REQUEST_MODEL = ResourceModel.builder() + .appImageConfigName(TEST_APP_IMAGE_CONFIG_NAME) + .build(); + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + SageMakerClient sdkClient; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + sdkClient = mock(SageMakerClient.class); + proxyClient = MOCK_PROXY(proxy, sdkClient); + } + + @Test + public void testCreateHandler_SimpleSuccess() { + final CreateAppImageConfigResponse createResponse = CreateAppImageConfigResponse.builder() + .appImageConfigArn(TEST_APP_IMAGE_CONFIG_ARN) + .build(); + + when(proxyClient.client().createAppImageConfig(any(CreateAppImageConfigRequest.class))) + .thenReturn(createResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + final ProgressEvent response = invokeHandleRequest(request); + + ResourceModel expectedModelFromResponse = ResourceModel.builder() + .appImageConfigArn(TEST_APP_IMAGE_CONFIG_ARN) + .appImageConfigName(TEST_APP_IMAGE_CONFIG_NAME) + .build(); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isEqualTo(expectedModelFromResponse); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testCreateHandler_InvalidRequestArn() { + final ResourceModel invalidModel = ResourceModel.builder() + .appImageConfigArn(TEST_APP_IMAGE_CONFIG_ARN) + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(invalidModel) + .build(); + + Exception exception = assertThrows( CfnInvalidRequestException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.InvalidRequest.getMessage(), + "The following property 'AppImageConfig' is not allowed to configured.")); + } + + @Test + public void testCreateHandler_ServiceInternalException() { + final AwsServiceException serviceInternalException = SageMakerException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(500) + .build(); + + when(proxyClient.client().createAppImageConfig(any(CreateAppImageConfigRequest.class))) + .thenThrow(serviceInternalException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows( CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.CREATE)); + } + + @Test + public void testCreateHandler_ResourceAlreadyExists_Fails() { + final ResourceInUseException resourceInUseException = ResourceInUseException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(400) + .build(); + + when(proxyClient.client().createAppImageConfig(any(CreateAppImageConfigRequest.class))) + .thenThrow(resourceInUseException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows( ResourceAlreadyExistsException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.AlreadyExists.getMessage(), + ResourceModel.TYPE_NAME, TEST_APP_IMAGE_CONFIG_NAME)); + } + + @Test + public void testCreateHandler_ResourceLimitExceededException() { + final ResourceLimitExceededException resourceLimitExceededException = ResourceLimitExceededException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(400) + .build(); + + when(proxyClient.client().createAppImageConfig(any(CreateAppImageConfigRequest.class))) + .thenThrow(resourceLimitExceededException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows(CfnServiceLimitExceededException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.ServiceLimitExceeded.getMessage(), + ResourceModel.TYPE_NAME, TEST_ERROR_MESSAGE)); + } + + @Test + public void testCreateHandler_ValidationFailure() { + final AwsErrorDetails awsErrorDetails = + AwsErrorDetails.builder().errorCode("ValidationError").errorMessage(TEST_ERROR_MESSAGE).build(); + + final AwsServiceException validationFailureException = SageMakerException.builder() + .awsErrorDetails(awsErrorDetails) + .build(); + + when(proxyClient.client().createAppImageConfig(any(CreateAppImageConfigRequest.class))) + .thenThrow(validationFailureException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows( CfnInvalidRequestException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.InvalidRequest.getMessage(), + TEST_ERROR_MESSAGE)); + } + + @Test + public void testCreateHandler_NoExceptionMessage() { + final AwsServiceException someException = SageMakerException.builder() + .statusCode(400) + .build(); + + when(proxyClient.client().createAppImageConfig(any(CreateAppImageConfigRequest.class))) + .thenThrow(someException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows( CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.CREATE)); + } + + private ProgressEvent invokeHandleRequest(ResourceHandlerRequest request) { + final CreateHandler handler = new CreateHandler(); + return handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + } +} \ No newline at end of file diff --git a/aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/DeleteHandlerTest.java b/aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/DeleteHandlerTest.java new file mode 100644 index 0000000..03658ce --- /dev/null +++ b/aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/DeleteHandlerTest.java @@ -0,0 +1,110 @@ +package software.amazon.sagemaker.appimageconfig; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.DeleteAppImageConfigRequest; +import software.amazon.awssdk.services.sagemaker.model.DeleteAppImageConfigResponse; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DeleteHandlerTest extends software.amazon.sagemaker.appimageconfig.AbstractTestBase { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + SageMakerClient sdkClient; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + sdkClient = mock(SageMakerClient.class); + proxyClient = MOCK_PROXY(proxy, sdkClient); + } + + @Test + public void testDeleteHandler_SimpleSuccess() { + final DeleteAppImageConfigResponse deleteAppImageConfigResponse = DeleteAppImageConfigResponse.builder().build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + when(proxyClient.client().deleteAppImageConfig(any(DeleteAppImageConfigRequest.class))) + .thenReturn(deleteAppImageConfigResponse); + + final ProgressEvent response = invokeHandleRequest(request); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo((OperationStatus.SUCCESS)); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + assertThat(response.getResourceModel()).isNull(); + } + + @Test + public void testDeleteHandler_ServiceInternalException() { + final AwsServiceException serviceInternalException = SageMakerException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(500) + .build(); + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + when(proxyClient.client().deleteAppImageConfig(any(DeleteAppImageConfigRequest.class))) + .thenThrow(serviceInternalException); + + Exception exception = assertThrows(CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.DELETE)); + } + + @Test + public void testDeleteHandler_ResourceDoesNotExists_Fails() { + when(proxyClient.client().deleteAppImageConfig(any(DeleteAppImageConfigRequest.class))) + .thenThrow(ResourceNotFoundException.class); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows(CfnNotFoundException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.NotFound.getMessage(), + ResourceModel.TYPE_NAME, TEST_APP_IMAGE_CONFIG_NAME)); + } + + private ProgressEvent invokeHandleRequest(ResourceHandlerRequest request) { + final DeleteHandler handler = new DeleteHandler(); + return handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + } +} diff --git a/aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/ListHandlerTest.java b/aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/ListHandlerTest.java new file mode 100644 index 0000000..f80fc72 --- /dev/null +++ b/aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/ListHandlerTest.java @@ -0,0 +1,140 @@ +package software.amazon.sagemaker.appimageconfig; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsErrorDetails; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.AppImageConfigDetails; +import software.amazon.awssdk.services.sagemaker.model.ListAppImageConfigsRequest; +import software.amazon.awssdk.services.sagemaker.model.ListAppImageConfigsResponse; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.cloudformation.exceptions.CfnServiceInternalErrorException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class ListHandlerTest extends software.amazon.sagemaker.appimageconfig.AbstractTestBase { + + private static final String OPERATION = "SageMaker::ListAppImageConfigs"; + public static final String TEST_TOKEN = "testToken"; + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + SageMakerClient sdkClient; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + sdkClient = mock(SageMakerClient.class); + proxyClient = MOCK_PROXY(proxy, sdkClient); + } + + @Test + public void testListHandler_SimpleSuccess() { + final AppImageConfigDetails appImageConfig = AppImageConfigDetails.builder() + .appImageConfigArn(TEST_APP_IMAGE_CONFIG_ARN) + .appImageConfigName(TEST_APP_IMAGE_CONFIG_NAME) + .build(); + + final ListAppImageConfigsResponse listResponse = ListAppImageConfigsResponse.builder() + .appImageConfigs(appImageConfig) + .nextToken(TEST_TOKEN) + .build(); + + when(proxyClient.client().listAppImageConfigs(any(ListAppImageConfigsRequest.class))) + .thenReturn(listResponse); + + final ResourceModel expectedResourceModel = ResourceModel.builder() + .appImageConfigName(TEST_APP_IMAGE_CONFIG_NAME) + .build(); + + List expectedModels = new ArrayList(); + expectedModels.add(expectedResourceModel); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + final ProgressEvent response = invokeHandleRequest(request); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isNull(); + assertThat(response.getResourceModels()).isEqualTo(expectedModels); + assertThat(response.getNextToken()).isEqualTo(TEST_TOKEN); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testListHandler_SimpleSuccess_NoAppImageConfigsExist() { + final ListAppImageConfigsResponse listResponse = ListAppImageConfigsResponse.builder() + .appImageConfigs(Collections.emptyList()) + .nextToken(null) + .build(); + + when(proxyClient.client().listAppImageConfigs(any(ListAppImageConfigsRequest.class))) + .thenReturn(listResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + final ProgressEvent response = invokeHandleRequest(request); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isNull(); + assertThat(response.getResourceModels()).isEqualTo(Collections.emptyList()); + assertThat(response.getNextToken()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testListHandler_ServiceInternalException() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("InternalError").errorMessage(OPERATION).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + when(proxyClient.client().listAppImageConfigs(any(ListAppImageConfigsRequest.class))) + .thenThrow(ex); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows(CfnServiceInternalErrorException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.ServiceInternalError.getMessage(), + OPERATION)); + } + + private ProgressEvent invokeHandleRequest(ResourceHandlerRequest request) { + final ListHandler handler = new ListHandler(); + return handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + } +} \ No newline at end of file diff --git a/aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/ReadHandlerTest.java b/aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/ReadHandlerTest.java new file mode 100644 index 0000000..f5622c4 --- /dev/null +++ b/aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/ReadHandlerTest.java @@ -0,0 +1,183 @@ +package software.amazon.sagemaker.appimageconfig; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.DescribeAppImageConfigRequest; +import software.amazon.awssdk.services.sagemaker.model.DescribeAppImageConfigResponse; +import software.amazon.awssdk.services.sagemaker.model.FileSystemConfig; +import software.amazon.awssdk.services.sagemaker.model.KernelGatewayImageConfig; +import software.amazon.awssdk.services.sagemaker.model.KernelSpec; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.time.Duration; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class ReadHandlerTest extends software.amazon.sagemaker.appimageconfig.AbstractTestBase { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + SageMakerClient sdkClient; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + sdkClient = mock(SageMakerClient.class); + proxyClient = MOCK_PROXY(proxy, sdkClient); + } + + @Test + public void testReadHandler_SimpleSuccess() { + final software.amazon.awssdk.services.sagemaker.model.KernelSpec kernelSpec = KernelSpec.builder() + .name(TEST_KERNEL_NAME) + .displayName(TEST_KERNEL_DISPLAY) + .build(); + + final software.amazon.awssdk.services.sagemaker.model.FileSystemConfig fileSystemConfig = + FileSystemConfig.builder() + .defaultGid(TEST_DEFAULT_GID) + .defaultUid(TEST_DEFAULT_UID) + .mountPath(TEST_MOUNT_PATH) + .build(); + + final software.amazon.awssdk.services.sagemaker.model.KernelGatewayImageConfig kernelGatewayImageConfig = + KernelGatewayImageConfig.builder() + .kernelSpecs(Collections.singletonList(kernelSpec)) + .fileSystemConfig(fileSystemConfig) + .build(); + + final DescribeAppImageConfigResponse describeResponse = DescribeAppImageConfigResponse.builder() + .appImageConfigArn(TEST_APP_IMAGE_CONFIG_ARN) + .appImageConfigName(TEST_APP_IMAGE_CONFIG_NAME) + .kernelGatewayImageConfig(kernelGatewayImageConfig) + .build(); + + when(proxyClient.client().describeAppImageConfig(any(DescribeAppImageConfigRequest.class))) + .thenReturn(describeResponse); + + final software.amazon.sagemaker.appimageconfig.KernelSpec expectedKernelSpec = + software.amazon.sagemaker.appimageconfig.KernelSpec.builder() + .name(TEST_KERNEL_NAME) + .displayName(TEST_KERNEL_DISPLAY) + .build(); + + final software.amazon.sagemaker.appimageconfig.FileSystemConfig expectedFileSystemConfig = + software.amazon.sagemaker.appimageconfig.FileSystemConfig.builder() + .defaultGid(TEST_DEFAULT_GID) + .defaultUid(TEST_DEFAULT_UID) + .mountPath(TEST_MOUNT_PATH) + .build(); + + final software.amazon.sagemaker.appimageconfig.KernelGatewayImageConfig expectedKernelGatewayImageConfig = + software.amazon.sagemaker.appimageconfig.KernelGatewayImageConfig.builder() + .kernelSpecs(Collections.singletonList(expectedKernelSpec)) + .fileSystemConfig(expectedFileSystemConfig) + .build(); + + final ResourceModel expectedResourceModel = ResourceModel.builder() + .appImageConfigArn(TEST_APP_IMAGE_CONFIG_ARN) + .appImageConfigName(TEST_APP_IMAGE_CONFIG_NAME) + .kernelGatewayImageConfig(expectedKernelGatewayImageConfig) + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + final ProgressEvent response = invokeHandleRequest(request); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isEqualTo(expectedResourceModel); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + verify(proxyClient.client()).describeAppImageConfig(any(DescribeAppImageConfigRequest.class)); + } + + @Test + public void testReadHandler_ServiceInternalException() { + final AwsServiceException serviceInternalException = SageMakerException.builder() + .message("test error message") + .statusCode(500) + .build(); + + when(proxyClient.client().describeAppImageConfig(any(DescribeAppImageConfigRequest.class))) + .thenThrow(serviceInternalException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows( CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.READ)); + } + + @Test + public void testReadHandler_AppImageConfigDoesNotExist_Fails() { + final AwsServiceException resourceNotFoundException = AwsServiceException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(400) + .build(); + + when(proxyClient.client().describeAppImageConfig(any(DescribeAppImageConfigRequest.class))) + .thenThrow(resourceNotFoundException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows( CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.READ)); + } + + @Test + public void testReadHandler_ResourceNotFoundException() { + when(proxyClient.client().describeAppImageConfig(any(DescribeAppImageConfigRequest.class))) + .thenThrow(ResourceNotFoundException.class); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows(CfnNotFoundException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.NotFound.getMessage(), + ResourceModel.TYPE_NAME, TEST_APP_IMAGE_CONFIG_NAME)); + } + + private ProgressEvent invokeHandleRequest(ResourceHandlerRequest request) { + final ReadHandler handler = new ReadHandler(); + return handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + } +} \ No newline at end of file diff --git a/aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/TranslatorTest.java b/aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/TranslatorTest.java new file mode 100644 index 0000000..9a510a5 --- /dev/null +++ b/aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/TranslatorTest.java @@ -0,0 +1,167 @@ +package software.amazon.sagemaker.appimageconfig; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.awscore.exception.AwsErrorDetails; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.model.ResourceLimitExceededException; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.cloudformation.exceptions.CfnAccessDeniedException; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnInvalidRequestException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.exceptions.CfnServiceInternalErrorException; +import software.amazon.cloudformation.exceptions.CfnServiceLimitExceededException; +import software.amazon.cloudformation.exceptions.CfnThrottlingException; +import software.amazon.cloudformation.proxy.HandlerErrorCode; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class TranslatorTest { + + public static final String TEST_OPERATION = "someOperation"; + public static final String RESOURCE_TYPE = "someResource"; + public static final String RESOURCE_NAME = "someType"; + public static final String ERROR_MESSAGE = "someError"; + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_UnauthorizedOperation() { + AwsErrorDetails errorDetails = + AwsErrorDetails.builder().errorCode("UnauthorizedOperation").errorMessage(ERROR_MESSAGE).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnAccessDeniedException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.AccessDenied.getMessage(), + ERROR_MESSAGE)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_InvalidParameter() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("InvalidParameter").build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnInvalidRequestException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_InvalidParameterValue() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("InvalidParameterValue").build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnInvalidRequestException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ValidationError() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("ValidationError").build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnInvalidRequestException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ValidationException() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("ValidationException").build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnInvalidRequestException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_InternalError() { + AwsErrorDetails errorDetails = + AwsErrorDetails.builder().errorCode("InternalError").errorMessage(ERROR_MESSAGE).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnServiceInternalErrorException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.ServiceInternalError.getMessage(), + ERROR_MESSAGE)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ServiceUnavailable() { + AwsErrorDetails errorDetails = + AwsErrorDetails.builder().errorCode("ServiceUnavailable").errorMessage(ERROR_MESSAGE).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnServiceInternalErrorException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.ServiceInternalError.getMessage(), + ERROR_MESSAGE)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ResourceLimitExceeded() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("ResourceLimitExceeded").build(); + AwsServiceException ex = ResourceLimitExceededException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnServiceLimitExceededException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ResourceNotFound() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("ResourceNotFound").build(); + AwsServiceException ex = ResourceNotFoundException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnNotFoundException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ThrottlingException() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("ThrottlingException").errorMessage(ERROR_MESSAGE).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnThrottlingException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.Throttling.getMessage(), + ERROR_MESSAGE)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_UnknownException() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("Unknown").errorMessage(ERROR_MESSAGE).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnGeneralServiceException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + ERROR_MESSAGE)); + } + + @Test + public void testGetHandlerError_NoErrorCode() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnGeneralServiceException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + TEST_OPERATION)); + } + + @Test + public void testGetHandlerError_NoErrorDetails() { + AwsServiceException ex = SageMakerException.builder().build(); + + Exception exception = assertThrows(CfnGeneralServiceException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + TEST_OPERATION)); + } +} \ No newline at end of file diff --git a/aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/UpdateHandlerTest.java b/aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/UpdateHandlerTest.java new file mode 100644 index 0000000..185817a --- /dev/null +++ b/aws-sagemaker-appimageconfig/src/test/java/software/amazon/sagemaker/appimageconfig/UpdateHandlerTest.java @@ -0,0 +1,131 @@ +package software.amazon.sagemaker.appimageconfig; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.ResourceLimitExceededException; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.awssdk.services.sagemaker.model.UpdateAppImageConfigRequest; +import software.amazon.awssdk.services.sagemaker.model.UpdateAppImageConfigResponse; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.exceptions.CfnServiceLimitExceededException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class UpdateHandlerTest extends software.amazon.sagemaker.appimageconfig.AbstractTestBase { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + SageMakerClient sdkClient; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + sdkClient = mock(SageMakerClient.class); + proxyClient = MOCK_PROXY(proxy, sdkClient); + } + + @Test + public void testUpdateHandler_SimpleSuccess() { + final UpdateAppImageConfigResponse updateAppImageConfigResponse = UpdateAppImageConfigResponse.builder().build(); + + when(proxyClient.client().updateAppImageConfig(any(UpdateAppImageConfigRequest.class))) + .thenReturn(updateAppImageConfigResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + final ProgressEvent response = invokeHandleRequest(request); + + ResourceModel expectedModelFromResponse = ResourceModel.builder() + .appImageConfigName(TEST_APP_IMAGE_CONFIG_NAME) + .build(); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isEqualTo(expectedModelFromResponse); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testUpdateHandler_ServiceInternalException() { + final AwsServiceException serviceInternalException = SageMakerException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(500) + .build(); + + when(proxyClient.client().updateAppImageConfig(any(UpdateAppImageConfigRequest.class))) + .thenThrow(serviceInternalException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows(CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.UPDATE.toString())); + } + + @Test + public void testUpdateHandler_ResourceNotFoundException() { + when(proxyClient.client().updateAppImageConfig(any(UpdateAppImageConfigRequest.class))) + .thenThrow(ResourceNotFoundException.class); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows(CfnNotFoundException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.NotFound.getMessage(), + ResourceModel.TYPE_NAME, TEST_APP_IMAGE_CONFIG_NAME)); + } + + @Test + public void testUpdateHandler_ResourceLimitExceededException() { + when(proxyClient.client().updateAppImageConfig(any(UpdateAppImageConfigRequest.class))) + .thenThrow(ResourceLimitExceededException.class); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows(CfnServiceLimitExceededException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.ServiceLimitExceeded.getMessage(), + ResourceModel.TYPE_NAME, null)); + } + + private ProgressEvent invokeHandleRequest(ResourceHandlerRequest request) { + final UpdateHandler handler = new UpdateHandler(); + return handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + } +} \ No newline at end of file diff --git a/aws-sagemaker-appimageconfig/template.yml b/aws-sagemaker-appimageconfig/template.yml new file mode 100644 index 0000000..684be0d --- /dev/null +++ b/aws-sagemaker-appimageconfig/template.yml @@ -0,0 +1,24 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Description: AWS SAM template for the AWS::SageMaker::AppImageConfig resource type + +Globals: + Function: + Timeout: 180 # docker start-up times can be long for SAM CLI + MemorySize: 256 + +Resources: + TypeFunction: + Type: AWS::Serverless::Function + Properties: + Handler: software.amazon.sagemaker.appimageconfig.HandlerWrapper::handleRequest + Runtime: java8 + CodeUri: ./target/aws-sagemaker-appimageconfig-handler-1.0-SNAPSHOT.jar + + TestEntrypoint: + Type: AWS::Serverless::Function + Properties: + Handler: software.amazon.sagemaker.appimageconfig.HandlerWrapper::testEntrypoint + Runtime: java8 + CodeUri: ./target/aws-sagemaker-appimageconfig-handler-1.0-SNAPSHOT.jar + diff --git a/aws-sagemaker-domain/.rpdk-config b/aws-sagemaker-domain/.rpdk-config new file mode 100644 index 0000000..9bc0e1d --- /dev/null +++ b/aws-sagemaker-domain/.rpdk-config @@ -0,0 +1,17 @@ +{ + "typeName": "AWS::SageMaker::Domain", + "language": "java", + "runtime": "java8", + "entrypoint": "software.amazon.sagemaker.domain.HandlerWrapper::handleRequest", + "testEntrypoint": "software.amazon.sagemaker.domain.HandlerWrapper::testEntrypoint", + "settings": { + "namespace": [ + "software", + "amazon", + "sagemaker", + "domain" + ], + "codegen_template_path": "default", + "protocolVersion": "2.0.0" + } +} diff --git a/aws-sagemaker-domain/README.md b/aws-sagemaker-domain/README.md new file mode 100644 index 0000000..399b17a --- /dev/null +++ b/aws-sagemaker-domain/README.md @@ -0,0 +1,12 @@ +# AWS::SageMaker::Domain + +Congratulations on starting development! Next steps: + +1. Write the JSON schema describing your resource, `aws-sagemaker-domain.json` +1. Implement your resource handlers. + +The RPDK will automatically generate the correct resource model from the schema whenever the project is built via Maven. You can also do this manually with the following command: `cfn generate`. + +> Please don't modify files under `target/generated-sources/rpdk`, as they will be automatically overwritten. + +The code uses [Lombok](https://projectlombok.org/), and [you may have to install IDE integrations](https://projectlombok.org/setup/overview) to enable auto-complete for Lombok-annotated classes. diff --git a/aws-sagemaker-domain/aws-sagemaker-domain.json b/aws-sagemaker-domain/aws-sagemaker-domain.json new file mode 100644 index 0000000..e220eb1 --- /dev/null +++ b/aws-sagemaker-domain/aws-sagemaker-domain.json @@ -0,0 +1,367 @@ +{ + "typeName": "AWS::SageMaker::Domain", + "description": "Resource Type definition for AWS::SageMaker::Domain", + "additionalProperties": false, + "properties": { + "DomainArn": { + "type": "string", + "description": "The Amazon Resource Name (ARN) of the created domain.", + "maxLength": 256, + "pattern": "arn:aws[a-z\\-]*:sagemaker:[a-z0-9\\-]*:[0-9]{12}:domain/.*" + }, + "Url": { + "type": "string", + "description": "The URL to the created domain.", + "maxLength": 1024 + }, + "AppNetworkAccessType": { + "type": "string", + "description": "Specifies the VPC used for non-EFS traffic. The default value is PublicInternetOnly.", + "enum": [ + "PublicInternetOnly", + "VpcOnly" + ] + }, + "AuthMode": { + "type": "string", + "description": "The mode of authentication that members use to access the domain.", + "enum": [ + "SSO", + "IAM" + ] + }, + "DefaultUserSettings": { + "$ref": "#/definitions/UserSettings", + "description": "The default user settings." + }, + "DomainName": { + "type": "string", + "description": "A name for the domain.", + "maxLength": 63, + "pattern": "^[a-zA-Z0-9](-*[a-zA-Z0-9]){0,62}" + }, + "KmsKeyId": { + "type": "string", + "description": "SageMaker uses AWS KMS to encrypt the EFS volume attached to the domain with an AWS managed customer master key (CMK) by default.", + "maxLength": 2048, + "pattern": ".*" + }, + "SubnetIds": { + "type": "array", + "description": "The VPC subnets that Studio uses for communication.", + "uniqueItems": false, + "minItems": 1, + "maxItems": 16, + "items": { + "type": "string", + "maxLength": 32, + "pattern": "[-0-9a-zA-Z]+" + } + }, + "Tags": { + "type": "array", + "description": "A list of tags to apply to the user profile.", + "uniqueItems": false, + "minItems": 0, + "maxItems": 50, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "VpcId": { + "type": "string", + "description": "The ID of the Amazon Virtual Private Cloud (VPC) that Studio uses for communication.", + "maxLength": 32, + "pattern": "[-0-9a-zA-Z]+" + }, + "DomainId": { + "type": "string", + "description": "The domain name.", + "maxLength": 63, + "pattern": "^d-(-*[a-z0-9])+" + }, + "HomeEfsFileSystemId": { + "type" : "string", + "description" : "The ID of the Amazon Elastic File System (EFS) managed by this Domain.", + "maxLength" : 32 + }, + "SingleSignOnManagedApplicationInstanceId": { + "type" : "string", + "description" : "The SSO managed application instance ID.", + "maxLength" : 256 + } + }, + "definitions": { + "UserSettings": { + "type": "object", + "description": "A collection of settings that apply to users of Amazon SageMaker Studio. These settings are specified when the CreateUserProfile API is called, and as DefaultUserSettings when the CreateDomain API is called.", + "additionalProperties": false, + "properties": { + "ExecutionRole": { + "type": "string", + "description": "The user profile Amazon Resource Name (ARN).", + "minLength": 20, + "maxLength": 2048, + "pattern": "^arn:aws[a-z\\-]*:iam::\\d{12}:role/?[a-zA-Z_0-9+=,.@\\-_/]+$" + }, + "JupyterServerAppSettings": { + "$ref": "#/definitions/JupyterServerAppSettings", + "description": "The Jupyter server's app settings." + }, + "KernelGatewayAppSettings": { + "$ref": "#/definitions/KernelGatewayAppSettings", + "description": "The kernel gateway app settings." + }, + "SecurityGroups": { + "type": "array", + "description": "The security groups for the Amazon Virtual Private Cloud (VPC) that Studio uses for communication.", + "uniqueItems": false, + "minItems": 0, + "maxItems": 5, + "items": { + "type": "string", + "maxLength": 32, + "pattern": "[-0-9a-zA-Z]+" + } + }, + "SharingSettings": { + "$ref": "#/definitions/SharingSettings", + "description": "The sharing settings." + } + } + }, + "JupyterServerAppSettings": { + "type": "object", + "description": "The JupyterServer app settings.", + "additionalProperties": false, + "properties": { + "DefaultResourceSpec": { + "$ref": "#/definitions/ResourceSpec" + } + } + }, + "ResourceSpec": { + "type": "object", + "additionalProperties": false, + "properties": { + "InstanceType": { + "type": "string", + "description": "The instance type that the image version runs on.", + "enum": [ + "system", + "ml.t3.micro", + "ml.t3.small", + "ml.t3.medium", + "ml.t3.large", + "ml.t3.xlarge", + "ml.t3.2xlarge", + "ml.m5.large", + "ml.m5.xlarge", + "ml.m5.2xlarge", + "ml.m5.4xlarge", + "ml.m5.8xlarge", + "ml.m5.12xlarge", + "ml.m5.16xlarge", + "ml.m5.24xlarge", + "ml.c5.large", + "ml.c5.xlarge", + "ml.c5.2xlarge", + "ml.c5.4xlarge", + "ml.c5.9xlarge", + "ml.c5.12xlarge", + "ml.c5.18xlarge", + "ml.c5.24xlarge", + "ml.p3.2xlarge", + "ml.p3.8xlarge", + "ml.p3.16xlarge", + "ml.g4dn.xlarge", + "ml.g4dn.2xlarge", + "ml.g4dn.4xlarge", + "ml.g4dn.8xlarge", + "ml.g4dn.12xlarge", + "ml.g4dn.16xlarge" + ] + }, + "SageMakerImageArn": { + "type": "string", + "description": "The ARN of the SageMaker image that the image version belongs to.", + "maxLength": 256, + "pattern": "^arn:aws(-[\\w]+)*:sagemaker:.+:[0-9]{12}:image/[a-z0-9]([-.]?[a-z0-9])*$" + }, + "SageMakerImageVersionArn": { + "type": "string", + "description": "The ARN of the image version created on the instance.", + "maxLength": 256, + "pattern": "^arn:aws(-[\\w]+)*:sagemaker:.+:[0-9]{12}:image-version/[a-z0-9]([-.]?[a-z0-9])*/[0-9]+$" + } + } + }, + "KernelGatewayAppSettings": { + "type": "object", + "description": "The kernel gateway app settings.", + "additionalProperties": false, + "properties": { + "CustomImages": { + "type": "array", + "description": "A list of custom SageMaker images that are configured to run as a KernelGateway app.", + "uniqueItems": false, + "minItems": 0, + "maxItems": 30, + "items": { + "$ref": "#/definitions/CustomImage" + } + }, + "DefaultResourceSpec": { + "$ref": "#/definitions/ResourceSpec", + "description": "The default instance type and the Amazon Resource Name (ARN) of the default SageMaker image used by the KernelGateway app." + } + } + }, + "CustomImage": { + "type": "object", + "description": "A custom SageMaker image.", + "additionalProperties": false, + "properties": { + "AppImageConfigName": { + "type": "string", + "description": "The Name of the AppImageConfig.", + "maxLength": 63, + "pattern": "^[a-zA-Z0-9](-*[a-zA-Z0-9]){0,62}" + }, + "ImageName": { + "type": "string", + "description": "The name of the CustomImage. Must be unique to your account.", + "maxLength": 63, + "pattern": "^[a-zA-Z0-9]([-.]?[a-zA-Z0-9]){0,62}$" + }, + "ImageVersionNumber": { + "type": "integer", + "description": "The version number of the CustomImage.", + "minimum": 0 + } + }, + "required": [ + "AppImageConfigName", + "ImageName" + ] + }, + "SharingSettings": { + "type": "object", + "description": "Specifies options when sharing an Amazon SageMaker Studio notebook. These settings are specified as part of DefaultUserSettings when the CreateDomain API is called, and as part of UserSettings when the CreateUserProfile API is called.", + "additionalProperties": false, + "properties": { + "NotebookOutputOption": { + "type": "string", + "description": "Whether to include the notebook cell output when sharing the notebook. The default is Disabled.", + "enum": [ + "Allowed", + "Disabled" + ] + }, + "S3KmsKeyId": { + "type": "string", + "description": "When NotebookOutputOption is Allowed, the AWS Key Management Service (KMS) encryption key ID used to encrypt the notebook cell output in the Amazon S3 bucket.", + "maxLength": 2048, + "pattern": ".*" + }, + "S3OutputPath": { + "type": "string", + "description": "When NotebookOutputOption is Allowed, the Amazon S3 bucket used to store the shared notebook snapshots.", + "maxLength": 1024, + "pattern": "^(https|s3)://([^/]+)/?(.*)$" + } + } + }, + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "Key": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "Key", + "Value" + ] + } + }, + "required": [ + "AuthMode", + "DefaultUserSettings", + "DomainName", + "SubnetIds", + "VpcId" + ], + "createOnlyProperties": [ + "/properties/AppNetworkAccessType", + "/properties/AuthMode", + "/properties/DomainName", + "/properties/KmsKeyId", + "/properties/SubnetIds", + "/properties/VpcId", + "/properties/Tags" + ], + "writeOnlyProperties": [ + "/properties/Tags" + ], + "primaryIdentifier": [ + "/properties/DomainId" + ], + "readOnlyProperties": [ + "/properties/DomainArn", + "/properties/Url", + "/properties/DomainId", + "/properties/HomeEfsFileSystemId", + "/properties/SingleSignOnManagedApplicationInstanceId" + ], + "handlers": { + "create": { + "permissions": [ + "sagemaker:CreateDomain", + "sagemaker:DescribeDomain", + "sagemaker:DescribeImage", + "sagemaker:DescribeImageVersion", + "iam:CreateServiceLinkedRole", + "iam:PassRole", + "efs:CreateFileSystem", + "kms:CreateGrant", + "kms:Decrypt", + "kms:DescribeKey", + "kms:GenerateDataKeyWithoutPlainText" + ] + }, + "read": { + "permissions": [ + "sagemaker:DescribeDomain" + ] + }, + "update": { + "permissions": [ + "sagemaker:UpdateDomain", + "sagemaker:DescribeDomain", + "sagemaker:DescribeImage", + "sagemaker:DescribeImageVersion", + "iam:PassRole" + ] + }, + "delete": { + "permissions": [ + "sagemaker:DeleteDomain", + "sagemaker:DescribeDomain" + ] + }, + "list": { + "permissions": [ + "sagemaker:ListDomains" + ] + } + } +} \ No newline at end of file diff --git a/aws-sagemaker-domain/docs/README.md b/aws-sagemaker-domain/docs/README.md new file mode 100644 index 0000000..b5fc714 --- /dev/null +++ b/aws-sagemaker-domain/docs/README.md @@ -0,0 +1,173 @@ +# AWS::SageMaker::Domain + +Resource Type definition for AWS::SageMaker::Domain + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Type" : "AWS::SageMaker::Domain",
+    "Properties" : {
+        "AppNetworkAccessType" : String,
+        "AuthMode" : String,
+        "DefaultUserSettings" : UserSettings,
+        "DomainName" : String,
+        "KmsKeyId" : String,
+        "SubnetIds" : [ String, ... ],
+        "Tags" : [ Tag, ... ],
+        "VpcId" : String,
+    }
+}
+
+ +### YAML + +
+Type: AWS::SageMaker::Domain
+Properties:
+    AppNetworkAccessType: String
+    AuthMode: String
+    DefaultUserSettings: UserSettings
+    DomainName: String
+    KmsKeyId: String
+    SubnetIds: 
+      - String
+    Tags: 
+      - Tag
+    VpcId: String
+
+ +## Properties + +#### AppNetworkAccessType + +Specifies the VPC used for non-EFS traffic. The default value is PublicInternetOnly. + +_Required_: No + +_Type_: String + +_Allowed Values_: PublicInternetOnly | VpcOnly + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### AuthMode + +The mode of authentication that members use to access the domain. + +_Required_: Yes + +_Type_: String + +_Allowed Values_: SSO | IAM + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### DefaultUserSettings + +A collection of settings that apply to users of Amazon SageMaker Studio. These settings are specified when the CreateUserProfile API is called, and as DefaultUserSettings when the CreateDomain API is called. + +_Required_: Yes + +_Type_: UserSettings + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### DomainName + +A name for the domain. + +_Required_: Yes + +_Type_: String + +_Maximum_: 63 + +_Pattern_: ^[a-zA-Z0-9](-*[a-zA-Z0-9]){0,62} + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### KmsKeyId + +SageMaker uses AWS KMS to encrypt the EFS volume attached to the domain with an AWS managed customer master key (CMK) by default. + +_Required_: No + +_Type_: String + +_Maximum_: 2048 + +_Pattern_: .* + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### SubnetIds + +The VPC subnets that Studio uses for communication. + +_Required_: Yes + +_Type_: List of String + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### Tags + +A list of tags to apply to the user profile. + +_Required_: No + +_Type_: List of Tag + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### VpcId + +The ID of the Amazon Virtual Private Cloud (VPC) that Studio uses for communication. + +_Required_: Yes + +_Type_: String + +_Maximum_: 32 + +_Pattern_: [-0-9a-zA-Z]+ + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +## Return Values + +### Ref + +When you pass the logical ID of this resource to the intrinsic `Ref` function, Ref returns the DomainId. + +### Fn::GetAtt + +The `Fn::GetAtt` intrinsic function returns a value for a specified attribute of this type. The following are the available attributes and sample return values. + +For more information about using the `Fn::GetAtt` intrinsic function, see [Fn::GetAtt](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getatt.html). + +#### DomainArn + +The Amazon Resource Name (ARN) of the created domain. + +#### Url + +The URL to the created domain. + +#### DomainId + +The domain name. + +#### HomeEfsFileSystemId + +The ID of the Amazon Elastic File System (EFS) managed by this Domain. + +#### SingleSignOnManagedApplicationInstanceId + +The SSO managed application instance ID. + diff --git a/aws-sagemaker-domain/docs/customimage.md b/aws-sagemaker-domain/docs/customimage.md new file mode 100644 index 0000000..1157100 --- /dev/null +++ b/aws-sagemaker-domain/docs/customimage.md @@ -0,0 +1,66 @@ +# AWS::SageMaker::Domain CustomImage + +A custom SageMaker image. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "AppImageConfigName" : String,
+    "ImageName" : String,
+    "ImageVersionNumber" : Integer
+}
+
+ +### YAML + +
+AppImageConfigName: String
+ImageName: String
+ImageVersionNumber: Integer
+
+ +## Properties + +#### AppImageConfigName + +The Name of the AppImageConfig. + +_Required_: Yes + +_Type_: String + +_Maximum_: 63 + +_Pattern_: ^[a-zA-Z0-9](-*[a-zA-Z0-9]){0,62} + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### ImageName + +The name of the CustomImage. Must be unique to your account. + +_Required_: Yes + +_Type_: String + +_Maximum_: 63 + +_Pattern_: ^[a-zA-Z0-9]([-.]?[a-zA-Z0-9]){0,62}$ + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### ImageVersionNumber + +The version number of the CustomImage. + +_Required_: No + +_Type_: Integer + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-sagemaker-domain/docs/jupyterserverappsettings.md b/aws-sagemaker-domain/docs/jupyterserverappsettings.md new file mode 100644 index 0000000..f66361e --- /dev/null +++ b/aws-sagemaker-domain/docs/jupyterserverappsettings.md @@ -0,0 +1,32 @@ +# AWS::SageMaker::Domain JupyterServerAppSettings + +The JupyterServer app settings. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "DefaultResourceSpec" : ResourceSpec
+}
+
+ +### YAML + +
+DefaultResourceSpec: ResourceSpec
+
+ +## Properties + +#### DefaultResourceSpec + +_Required_: No + +_Type_: ResourceSpec + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-sagemaker-domain/docs/kernelgatewayappsettings.md b/aws-sagemaker-domain/docs/kernelgatewayappsettings.md new file mode 100644 index 0000000..1d1b494 --- /dev/null +++ b/aws-sagemaker-domain/docs/kernelgatewayappsettings.md @@ -0,0 +1,45 @@ +# AWS::SageMaker::Domain KernelGatewayAppSettings + +The kernel gateway app settings. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "CustomImages" : [ CustomImage, ... ],
+    "DefaultResourceSpec" : ResourceSpec
+}
+
+ +### YAML + +
+CustomImages: 
+      - CustomImage
+DefaultResourceSpec: ResourceSpec
+
+ +## Properties + +#### CustomImages + +A list of custom SageMaker images that are configured to run as a KernelGateway app. + +_Required_: No + +_Type_: List of CustomImage + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### DefaultResourceSpec + +_Required_: No + +_Type_: ResourceSpec + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-sagemaker-domain/docs/resourcespec.md b/aws-sagemaker-domain/docs/resourcespec.md new file mode 100644 index 0000000..4848ed8 --- /dev/null +++ b/aws-sagemaker-domain/docs/resourcespec.md @@ -0,0 +1,66 @@ +# AWS::SageMaker::Domain ResourceSpec + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "InstanceType" : String,
+    "SageMakerImageArn" : String,
+    "SageMakerImageVersionArn" : String
+}
+
+ +### YAML + +
+InstanceType: String
+SageMakerImageArn: String
+SageMakerImageVersionArn: String
+
+ +## Properties + +#### InstanceType + +The instance type that the image version runs on. + +_Required_: No + +_Type_: String + +_Allowed Values_: system | ml.t3.micro | ml.t3.small | ml.t3.medium | ml.t3.large | ml.t3.xlarge | ml.t3.2xlarge | ml.m5.large | ml.m5.xlarge | ml.m5.2xlarge | ml.m5.4xlarge | ml.m5.8xlarge | ml.m5.12xlarge | ml.m5.16xlarge | ml.m5.24xlarge | ml.c5.large | ml.c5.xlarge | ml.c5.2xlarge | ml.c5.4xlarge | ml.c5.9xlarge | ml.c5.12xlarge | ml.c5.18xlarge | ml.c5.24xlarge | ml.p3.2xlarge | ml.p3.8xlarge | ml.p3.16xlarge | ml.g4dn.xlarge | ml.g4dn.2xlarge | ml.g4dn.4xlarge | ml.g4dn.8xlarge | ml.g4dn.12xlarge | ml.g4dn.16xlarge + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### SageMakerImageArn + +The ARN of the SageMaker image that the image version belongs to. + +_Required_: No + +_Type_: String + +_Maximum_: 256 + +_Pattern_: ^arn:aws(-[\w]+)*:sagemaker:.+:[0-9]{12}:image/[a-z0-9]([-.]?[a-z0-9])*$ + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### SageMakerImageVersionArn + +The ARN of the image version created on the instance. + +_Required_: No + +_Type_: String + +_Maximum_: 256 + +_Pattern_: ^arn:aws(-[\w]+)*:sagemaker:.+:[0-9]{12}:image-version/[a-z0-9]([-.]?[a-z0-9])*/[0-9]+$ + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-sagemaker-domain/docs/sharingsettings.md b/aws-sagemaker-domain/docs/sharingsettings.md new file mode 100644 index 0000000..44731b5 --- /dev/null +++ b/aws-sagemaker-domain/docs/sharingsettings.md @@ -0,0 +1,68 @@ +# AWS::SageMaker::Domain SharingSettings + +Specifies options when sharing an Amazon SageMaker Studio notebook. These settings are specified as part of DefaultUserSettings when the CreateDomain API is called, and as part of UserSettings when the CreateUserProfile API is called. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "NotebookOutputOption" : String,
+    "S3KmsKeyId" : String,
+    "S3OutputPath" : String
+}
+
+ +### YAML + +
+NotebookOutputOption: String
+S3KmsKeyId: String
+S3OutputPath: String
+
+ +## Properties + +#### NotebookOutputOption + +Whether to include the notebook cell output when sharing the notebook. The default is Disabled. + +_Required_: No + +_Type_: String + +_Allowed Values_: Allowed | Disabled + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### S3KmsKeyId + +When NotebookOutputOption is Allowed, the AWS Key Management Service (KMS) encryption key ID used to encrypt the notebook cell output in the Amazon S3 bucket. + +_Required_: No + +_Type_: String + +_Maximum_: 2048 + +_Pattern_: .* + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### S3OutputPath + +When NotebookOutputOption is Allowed, the Amazon S3 bucket used to store the shared notebook snapshots. + +_Required_: No + +_Type_: String + +_Maximum_: 1024 + +_Pattern_: ^(https|s3)://([^/]+)/?(.*)$ + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-sagemaker-domain/docs/tag.md b/aws-sagemaker-domain/docs/tag.md new file mode 100644 index 0000000..4b12eae --- /dev/null +++ b/aws-sagemaker-domain/docs/tag.md @@ -0,0 +1,48 @@ +# AWS::SageMaker::Domain Tag + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Value" : String,
+    "Key" : String
+}
+
+ +### YAML + +
+Value: String
+Key: String
+
+ +## Properties + +#### Value + +_Required_: Yes + +_Type_: String + +_Minimum_: 1 + +_Maximum_: 128 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Key + +_Required_: Yes + +_Type_: String + +_Minimum_: 1 + +_Maximum_: 128 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-sagemaker-domain/docs/usersettings.md b/aws-sagemaker-domain/docs/usersettings.md new file mode 100644 index 0000000..49f342d --- /dev/null +++ b/aws-sagemaker-domain/docs/usersettings.md @@ -0,0 +1,89 @@ +# AWS::SageMaker::Domain UserSettings + +A collection of settings that apply to users of Amazon SageMaker Studio. These settings are specified when the CreateUserProfile API is called, and as DefaultUserSettings when the CreateDomain API is called. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "ExecutionRole" : String,
+    "JupyterServerAppSettings" : JupyterServerAppSettings,
+    "KernelGatewayAppSettings" : KernelGatewayAppSettings,
+    "SecurityGroups" : [ String, ... ],
+    "SharingSettings" : SharingSettings
+}
+
+ +### YAML + +
+ExecutionRole: String
+JupyterServerAppSettings: JupyterServerAppSettings
+KernelGatewayAppSettings: KernelGatewayAppSettings
+SecurityGroups: 
+      - String
+SharingSettings: SharingSettings
+
+ +## Properties + +#### ExecutionRole + +The user profile Amazon Resource Name (ARN). + +_Required_: No + +_Type_: String + +_Minimum_: 20 + +_Maximum_: 2048 + +_Pattern_: ^arn:aws[a-z\-]*:iam::\d{12}:role/?[a-zA-Z_0-9+=,.@\-_/]+$ + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### JupyterServerAppSettings + +The JupyterServer app settings. + +_Required_: No + +_Type_: JupyterServerAppSettings + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### KernelGatewayAppSettings + +The kernel gateway app settings. + +_Required_: No + +_Type_: KernelGatewayAppSettings + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### SecurityGroups + +The security groups for the Amazon Virtual Private Cloud (VPC) that Studio uses for communication. + +_Required_: No + +_Type_: List of String + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### SharingSettings + +Specifies options when sharing an Amazon SageMaker Studio notebook. These settings are specified as part of DefaultUserSettings when the CreateDomain API is called, and as part of UserSettings when the CreateUserProfile API is called. + +_Required_: No + +_Type_: SharingSettings + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-sagemaker-domain/lombok.config b/aws-sagemaker-domain/lombok.config new file mode 100644 index 0000000..7a21e88 --- /dev/null +++ b/aws-sagemaker-domain/lombok.config @@ -0,0 +1 @@ +lombok.addLombokGeneratedAnnotation = true diff --git a/aws-sagemaker-domain/pom.xml b/aws-sagemaker-domain/pom.xml new file mode 100644 index 0000000..26e11cc --- /dev/null +++ b/aws-sagemaker-domain/pom.xml @@ -0,0 +1,210 @@ + + + 4.0.0 + + software.amazon.sagemaker.domain + aws-sagemaker-domain-handler + aws-sagemaker-domain-handler + 1.0-SNAPSHOT + jar + + + 1.8 + 1.8 + UTF-8 + UTF-8 + + + + + + software.amazon.awssdk + sagemaker + 2.15.41 + + + + software.amazon.cloudformation + aws-cloudformation-rpdk-java-plugin + [2.0.0,3.0.0) + + + + org.projectlombok + lombok + 1.18.4 + provided + + + + + org.assertj + assertj-core + 3.12.2 + test + + + + org.junit.jupiter + junit-jupiter + 5.5.0-M1 + test + + + + org.mockito + mockito-core + 3.6.0 + test + + + + org.mockito + mockito-junit-jupiter + 3.6.0 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + -Xlint:all,-options,-processing + -Werror + + + + + org.apache.maven.plugins + maven-shade-plugin + 2.3 + + false + + + + package + + shade + + + + + + org.codehaus.mojo + exec-maven-plugin + 1.6.0 + + + generate + generate-sources + + exec + + + cfn + generate + ${project.basedir} + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.0.0 + + + add-source + generate-sources + + add-source + + + + ${project.basedir}/target/generated-sources/rpdk + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 2.4 + + + maven-surefire-plugin + 3.0.0-M3 + + + org.jacoco + jacoco-maven-plugin + 0.8.4 + + + **/BaseConfiguration* + **/BaseHandler* + **/HandlerWrapper* + **/ResourceModel* + + + + + + prepare-agent + + + + report + test + + report + + + + jacoco-check + + check + + + + + PACKAGE + + + BRANCH + COVEREDRATIO + 0.8 + + + INSTRUCTION + COVEREDRATIO + 0.8 + + + + + + + + + + + + ${project.basedir} + + aws-sagemaker-domain.json + + + + + diff --git a/aws-sagemaker-domain/resource-role.yaml b/aws-sagemaker-domain/resource-role.yaml new file mode 100644 index 0000000..77a26cc --- /dev/null +++ b/aws-sagemaker-domain/resource-role.yaml @@ -0,0 +1,44 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: > + This CloudFormation template creates a role assumed by CloudFormation + during CRUDL operations to mutate resources on behalf of the customer. + +Resources: + ExecutionRole: + Type: AWS::IAM::Role + Properties: + MaxSessionDuration: 8400 + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: resources.cloudformation.amazonaws.com + Action: sts:AssumeRole + Path: "/" + Policies: + - PolicyName: ResourceTypePolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - "efs:CreateFileSystem" + - "iam:CreateServiceLinkedRole" + - "iam:PassRole" + - "kms:CreateGrant" + - "kms:Decrypt" + - "kms:DescribeKey" + - "kms:GenerateDataKeyWithoutPlainText" + - "sagemaker:CreateDomain" + - "sagemaker:DeleteDomain" + - "sagemaker:DescribeDomain" + - "sagemaker:DescribeImage" + - "sagemaker:DescribeImageVersion" + - "sagemaker:ListDomains" + - "sagemaker:UpdateDomain" + Resource: "*" +Outputs: + ExecutionRoleArn: + Value: + Fn::GetAtt: ExecutionRole.Arn diff --git a/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/BaseHandlerStd.java b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/BaseHandlerStd.java new file mode 100644 index 0000000..26aab73 --- /dev/null +++ b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/BaseHandlerStd.java @@ -0,0 +1,36 @@ +package software.amazon.sagemaker.domain; + +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +/** + * Common handler function definition for Create/Read/Update/Delete/List handlers. + */ +public abstract class BaseHandlerStd extends BaseHandler { + + @Override + public final ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final Logger logger) { + return handleRequest( + proxy, + request, + callbackContext != null ? callbackContext : new CallbackContext(), + proxy.newProxy(ClientBuilder::getClient), + logger + ); + } + + protected abstract ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger); +} \ No newline at end of file diff --git a/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/CallbackContext.java b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/CallbackContext.java new file mode 100644 index 0000000..ed2cb24 --- /dev/null +++ b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/CallbackContext.java @@ -0,0 +1,10 @@ +package software.amazon.sagemaker.domain; + +import software.amazon.cloudformation.proxy.StdCallbackContext; + +@lombok.Getter +@lombok.Setter +@lombok.ToString +@lombok.EqualsAndHashCode(callSuper = true) +public class CallbackContext extends StdCallbackContext { +} diff --git a/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/ClientBuilder.java b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/ClientBuilder.java new file mode 100644 index 0000000..d9b6f36 --- /dev/null +++ b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/ClientBuilder.java @@ -0,0 +1,12 @@ +package software.amazon.sagemaker.domain; + +import software.amazon.awssdk.services.sagemaker.SageMakerClient; + +/** + * Provides APIs to build the service client. + */ +public class ClientBuilder { + public static SageMakerClient getClient() { + return SageMakerClient.builder().build(); + } +} diff --git a/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/Configuration.java b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/Configuration.java new file mode 100644 index 0000000..f8953fa --- /dev/null +++ b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/Configuration.java @@ -0,0 +1,8 @@ +package software.amazon.sagemaker.domain; + +class Configuration extends BaseConfiguration { + + public Configuration() { + super("aws-sagemaker-domain.json"); + } +} diff --git a/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/CreateHandler.java b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/CreateHandler.java new file mode 100644 index 0000000..2af5074 --- /dev/null +++ b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/CreateHandler.java @@ -0,0 +1,179 @@ +package software.amazon.sagemaker.domain; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.CreateDomainRequest; +import software.amazon.awssdk.services.sagemaker.model.CreateDomainResponse; +import software.amazon.awssdk.services.sagemaker.model.DescribeDomainResponse; +import software.amazon.awssdk.services.sagemaker.model.DomainStatus; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnInvalidRequestException; +import software.amazon.cloudformation.exceptions.CfnNotStabilizedException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class CreateHandler extends BaseHandlerStd { + + private static final String OPERATION = "AWS-SageMaker-Domain::Create"; + private static final String READ_ONLY_PROPERTY_ERROR_MESSAGE = "The following property '%s' is not allowed to configured."; + + private Logger logger; + + protected ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + + final ResourceModel model = request.getDesiredResourceState(); + + // read only properties are not allowed to be set by the user during creation. + // https://github.com/aws-cloudformation/aws-cloudformation-resource-schema/issues/102 + if (callbackContext.callGraphs().isEmpty()) { + if (model.getDomainArn() != null) { + throw new CfnInvalidRequestException(String.format(READ_ONLY_PROPERTY_ERROR_MESSAGE, "DomainArn")); + } + + if (model.getDomainId() != null) { + throw new CfnInvalidRequestException(String.format(READ_ONLY_PROPERTY_ERROR_MESSAGE, "DomainId")); + } + + if (model.getUrl() != null) { + throw new CfnInvalidRequestException(String.format(READ_ONLY_PROPERTY_ERROR_MESSAGE, "Url")); + } + + if (model.getHomeEfsFileSystemId() != null) { + throw new CfnInvalidRequestException(String.format(READ_ONLY_PROPERTY_ERROR_MESSAGE, "HomeEfsFileSystemId")); + } + + if (model.getSingleSignOnManagedApplicationInstanceId() != null) { + throw new CfnInvalidRequestException(String.format(READ_ONLY_PROPERTY_ERROR_MESSAGE, "SingleSignOnManagedApplicationInstanceId")); + } + } + + return ProgressEvent.progress(model, callbackContext) + .then(progress -> proxy.initiate(OPERATION, proxyClient, model, callbackContext) + .translateToServiceRequest(TranslatorForRequest::translateToCreateRequest) + .makeServiceCall(this::createResource) + .stabilize(this::stabilizedOnCreate) + .done(createResponse -> constructResourceModelFromResponse(createResponse, model, proxyClient)) + ); + } + + /** + * Client invocation of the create request through the proxyClient + * + * @param createRequest aws service create resource request + * @param proxyClient aws service client to make the call + * @return create resource response + */ + private CreateDomainResponse createResource( + final CreateDomainRequest createRequest, + final ProxyClient proxyClient) { + CreateDomainResponse response = null; + try { + response = proxyClient.injectCredentialsAndInvokeV2( + createRequest, proxyClient.client()::createDomain); + } catch (final AwsServiceException e) { + Translator.throwCfnException(Action.CREATE.toString(), ResourceModel.TYPE_NAME, createRequest.domainName(), e); + } + + // Wait 5 seconds for Domain to show up in List and Describe calls. + try { + Thread.sleep(5*1000); + } catch(InterruptedException ignored) { + } + + return response; + } + + /** + * Ensure resource has moved from pending to terminal state. + * + * @param awsResponse the aws service response to create a resource + * @param proxyClient the aws service client to make the call + * @param model Resource Model + * @param callbackContext call back context + * @return boolean flag indicate if the creation is stabilized + */ + private boolean stabilizedOnCreate( + final CreateDomainRequest awsRequest, + final CreateDomainResponse awsResponse, + final ProxyClient proxyClient, + final ResourceModel model, + final CallbackContext callbackContext) { + if (model.getDomainId() == null) { + model.setDomainId(getDomainIdFromArn(awsResponse.domainArn())); + } + + final DomainStatus DomainStatus; + try { + DomainStatus = proxyClient.injectCredentialsAndInvokeV2( + TranslatorForRequest.translateToReadRequest(model), + proxyClient.client()::describeDomain).status(); + } catch (ResourceNotFoundException rnfe) { + logger.log(String.format("Resource not found for %s, stabilizing.", model.getPrimaryIdentifier())); + return false; + } + + switch (DomainStatus) { + case IN_SERVICE: + logger.log(String.format("%s [%s] has been stabilized with status %s.", ResourceModel.TYPE_NAME, + model.getPrimaryIdentifier(), DomainStatus)); + return true; + case PENDING: + logger.log(String.format("%s [%s] is stabilizing.", ResourceModel.TYPE_NAME, + model.getPrimaryIdentifier())); + return false; + default: + logger.log(String.format("%s [%s] failed to stabilize with status: %s.", ResourceModel.TYPE_NAME, + model.getPrimaryIdentifier(), DomainStatus)); + throw new CfnNotStabilizedException(ResourceModel.TYPE_NAME, model.getDomainName()); + } + } + + /** + * Build the Progress Event object from the describe response. + * + * @param awsResponse the aws service response to create a resource + * @param model resource model + * @param proxyClient the aws service client to make the call + * @return progressEvent indicating success + */ + private ProgressEvent constructResourceModelFromResponse( + final CreateDomainResponse awsResponse, + final ResourceModel model, + final ProxyClient proxyClient) { + if (model.getDomainId() == null) { + model.setDomainId(getDomainIdFromArn(awsResponse.domainArn())); + } + + DescribeDomainResponse response = null; + try { + response = proxyClient.injectCredentialsAndInvokeV2( + TranslatorForRequest.translateToReadRequest(model), + proxyClient.client()::describeDomain); + } catch (ResourceNotFoundException e) { + Translator.throwCfnException(Action.CREATE.toString(), ResourceModel.TYPE_NAME, model.getPrimaryIdentifier().toString(), e); + } + + model.setDomainArn(response.domainArn()); + model.setUrl(response.url()); + model.setHomeEfsFileSystemId(response.homeEfsFileSystemId()); + model.setSingleSignOnManagedApplicationInstanceId(response.singleSignOnManagedApplicationInstanceId()); + + return ProgressEvent.defaultSuccessHandler(model); + } + + private static String getDomainIdFromArn(final String arn) { + String[] splitArn = arn.split("/"); + return splitArn[splitArn.length - 1]; + } +} diff --git a/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/DeleteHandler.java b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/DeleteHandler.java new file mode 100644 index 0000000..d942f33 --- /dev/null +++ b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/DeleteHandler.java @@ -0,0 +1,110 @@ +package software.amazon.sagemaker.domain; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.DeleteDomainRequest; +import software.amazon.awssdk.services.sagemaker.model.DeleteDomainResponse; +import software.amazon.awssdk.services.sagemaker.model.DomainStatus; +import software.amazon.awssdk.services.sagemaker.model.ResourceInUseException; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnNotStabilizedException; +import software.amazon.cloudformation.exceptions.CfnResourceConflictException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class DeleteHandler extends BaseHandlerStd { + + private static final String OPERATION = "AWS-SageMaker-Domain::Delete"; + + private Logger logger; + + protected ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + + final ResourceModel model = request.getDesiredResourceState(); + + return ProgressEvent.progress(model, callbackContext) + .then(progress -> + proxy.initiate(OPERATION, proxyClient, model, callbackContext) + .translateToServiceRequest(TranslatorForRequest::translateToDeleteRequest) + .makeServiceCall(this::deleteResource) + .stabilize(this::stabilizedOnDelete) + .done(awsResponse -> ProgressEvent.builder() + .status(OperationStatus.SUCCESS) + .build())); + } + + /** + * Implement client invocation of the delete request through the proxyClient. + * + * @param awsRequest the aws service delete resource request + * @param proxyClient the aws service client to make the call + * @return delete resource response + */ + private DeleteDomainResponse deleteResource( + final DeleteDomainRequest awsRequest, + final ProxyClient proxyClient) { + + DeleteDomainResponse response = null; + try { + response = proxyClient.injectCredentialsAndInvokeV2(awsRequest, proxyClient.client()::deleteDomain); + } catch (final ResourceInUseException riue) { + // ResourceInUseException is handled differently for deletes, as deletes can fail due to associated UserProfiles and Apps + throw new CfnResourceConflictException(ResourceModel.TYPE_NAME, awsRequest.domainId(), riue.getMessage(), riue); + } catch (final AwsServiceException e) { + Translator.throwCfnException(Action.DELETE.toString(), ResourceModel.TYPE_NAME, + awsRequest.domainId(), e); + } + + return response; + } + + /** + * Ensure resource has moved from pending to terminal state. + * + * @param awsRequest the aws service delete resource request + * @param awsResponse the aws service delete resource response + * @param proxyClient the aws service client to make the call + * @param model resource model + * @param callbackContext callback context + * @return boolean to indicate if the deletion is stabilized + */ + private boolean stabilizedOnDelete( + final DeleteDomainRequest awsRequest, + final DeleteDomainResponse awsResponse, + final ProxyClient proxyClient, + final ResourceModel model, + final CallbackContext callbackContext) { + try { + final DomainStatus DomainStatus = + proxyClient.injectCredentialsAndInvokeV2(TranslatorForRequest.translateToReadRequest(model), + proxyClient.client()::describeDomain).status(); + + switch (DomainStatus) { + case DELETING: + logger.log(String.format("%s with name [%s] is stabilizing while delete.", + ResourceModel.TYPE_NAME, model.getPrimaryIdentifier())); + return false; + case PENDING: + logger.log(String.format("%s [%s] is stabilizing.", ResourceModel.TYPE_NAME, + model.getPrimaryIdentifier())); + return false; + default: + throw new CfnNotStabilizedException(ResourceModel.TYPE_NAME, model.getPrimaryIdentifier().toString()); + } + } catch (ResourceNotFoundException e) { + return true; + } + } +} diff --git a/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/ListHandler.java b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/ListHandler.java new file mode 100644 index 0000000..c9e1b67 --- /dev/null +++ b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/ListHandler.java @@ -0,0 +1,74 @@ +package software.amazon.sagemaker.domain; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.ListDomainsRequest; +import software.amazon.awssdk.services.sagemaker.model.ListDomainsResponse; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class ListHandler extends BaseHandlerStd { + + private static final String OPERATION = "AWS-SageMaker-Domain::List"; + + private Logger logger; + + @Override + public ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + + final ResourceModel model = request.getDesiredResourceState(); + + return proxy.initiate(OPERATION, proxyClient, model, callbackContext) + .translateToServiceRequest(resourceModel -> TranslatorForRequest.translateToListRequest(request.getNextToken())) + .makeServiceCall(this::listResources) + .done(this::constructResourceModelFromResponse); + } + + /** + * Client invocation of the list request through the proxyClient. + * + * @param awsRequest the aws service request to list a resource + * @param proxyClient the aws service client to make the call + * @return aws service list resource response + */ + private ListDomainsResponse listResources( + final ListDomainsRequest awsRequest, + final ProxyClient proxyClient) { + + ListDomainsResponse response = null; + try { + response = proxyClient.injectCredentialsAndInvokeV2(awsRequest, proxyClient.client()::listDomains); + } catch (final AwsServiceException e) { + Translator.throwCfnException(Action.LIST.toString(), ResourceModel.TYPE_NAME, null, e); + } + + return response; + } + + /** + * Build the Progress Event object from the list resource response. + * + * @param listResponse the aws service list resource response + * @return progressEvent indicating success, in progress, delay, callback or failed state + */ + private ProgressEvent constructResourceModelFromResponse( + final ListDomainsResponse listResponse) { + return ProgressEvent.builder() + .nextToken(listResponse.nextToken()) + .resourceModels(TranslatorForResponse.translateFromListResponse(listResponse)) + .status(OperationStatus.SUCCESS) + .build(); + } +} diff --git a/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/ReadHandler.java b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/ReadHandler.java new file mode 100644 index 0000000..1f48a34 --- /dev/null +++ b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/ReadHandler.java @@ -0,0 +1,72 @@ +package software.amazon.sagemaker.domain; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.DescribeDomainRequest; +import software.amazon.awssdk.services.sagemaker.model.DescribeDomainResponse; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class ReadHandler extends BaseHandlerStd { + + private static final String OPERATION = "AWS-SageMaker-Domain::Read"; + + private Logger logger; + + + @Override + public ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + final ResourceModel model = request.getDesiredResourceState(); + + return proxy.initiate(OPERATION, proxyClient, model, callbackContext) + .translateToServiceRequest(TranslatorForRequest::translateToReadRequest) + .makeServiceCall((awsRequest, sdkProxyClient) -> readResource(awsRequest, sdkProxyClient, model)) + .done(this::constructResourceModelFromResponse); + } + + /** + * Client invocation of the read request through the proxyClient. + * + * @param awsRequest the aws service describe resource request + * @param proxyClient the aws service client to make the call + * @param model Resource Model + * @return describe resource response + */ + private DescribeDomainResponse readResource( + final DescribeDomainRequest awsRequest, + final ProxyClient proxyClient, + final ResourceModel model) { + + DescribeDomainResponse response = null; + try { + response = proxyClient.injectCredentialsAndInvokeV2(awsRequest, proxyClient.client()::describeDomain); + } catch (final AwsServiceException e) { + Translator.throwCfnException(Action.READ.toString(), ResourceModel.TYPE_NAME, + awsRequest.domainId(), e); + } + + return response; + } + + /** + * Build the Progress Event object from the SageMaker DescribeDomain response. + * + * @param awsResponse the aws service list resource response + * @return progressEvent indicating either success, delay, callback or failed state + */ + private ProgressEvent constructResourceModelFromResponse( + final DescribeDomainResponse awsResponse) { + return ProgressEvent.defaultSuccessHandler(TranslatorForResponse.translateFromReadResponse(awsResponse)); + } +} diff --git a/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/Translator.java b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/Translator.java new file mode 100644 index 0000000..14a22db --- /dev/null +++ b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/Translator.java @@ -0,0 +1,81 @@ +package software.amazon.sagemaker.domain; + +import org.apache.commons.lang3.StringUtils; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.model.ResourceInUseException; +import software.amazon.awssdk.services.sagemaker.model.ResourceLimitExceededException; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.cloudformation.exceptions.CfnAccessDeniedException; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnInvalidRequestException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.exceptions.CfnServiceInternalErrorException; +import software.amazon.cloudformation.exceptions.CfnServiceLimitExceededException; +import software.amazon.cloudformation.exceptions.CfnThrottlingException; +import software.amazon.cloudformation.exceptions.ResourceAlreadyExistsException; + +import java.util.Collection; +import java.util.Optional; +import java.util.stream.Stream; + +/** + * Contains common methods required by other translators. + */ +public class Translator { + + /** + * Throws a Cfn exception for the corresponding error code of the given exception. + * + * @param operation operation + * @param resourceType resource type + * @param resourceName resource name + * @param e exception + */ + static void throwCfnException( + final String operation, + final String resourceType, + final String resourceName, + final AwsServiceException e + ) { + + if (e instanceof ResourceInUseException) { + throw new ResourceAlreadyExistsException(resourceType, resourceName, e); + } + + if (e instanceof ResourceNotFoundException) { + throw new CfnNotFoundException(resourceType, resourceName, e); + } + + if (e instanceof ResourceLimitExceededException) { + throw new CfnServiceLimitExceededException(resourceType, e.getMessage(), e); + } + + if(e.awsErrorDetails() != null && StringUtils.isNotBlank(e.awsErrorDetails().errorCode())) { + String errorMessage = e.awsErrorDetails().errorMessage(); + switch (e.awsErrorDetails().errorCode()) { + case "UnauthorizedOperation": + throw new CfnAccessDeniedException(errorMessage, e); + case "InvalidParameter": + case "InvalidParameterValue": + case "ValidationError": + case "ValidationException": + throw new CfnInvalidRequestException(errorMessage, e); + case "InternalError": + case "ServiceUnavailable": + throw new CfnServiceInternalErrorException(errorMessage, e); + case "ThrottlingException": + throw new CfnThrottlingException(errorMessage, e); + default: + throw new CfnGeneralServiceException(errorMessage, e); + } + } + + throw new CfnGeneralServiceException(operation, e); + } + + static Stream streamOfOrEmpty(final Collection collection) { + return Optional.ofNullable(collection) + .map(Collection::stream) + .orElseGet(Stream::empty); + } +} \ No newline at end of file diff --git a/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/TranslatorForRequest.java b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/TranslatorForRequest.java new file mode 100644 index 0000000..8205f20 --- /dev/null +++ b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/TranslatorForRequest.java @@ -0,0 +1,165 @@ +package software.amazon.sagemaker.domain; + +import software.amazon.awssdk.services.sagemaker.model.CreateDomainRequest; +import software.amazon.awssdk.services.sagemaker.model.DeleteDomainRequest; +import software.amazon.awssdk.services.sagemaker.model.DescribeDomainRequest; +import software.amazon.awssdk.services.sagemaker.model.ListDomainsRequest; +import software.amazon.awssdk.services.sagemaker.model.Tag; +import software.amazon.awssdk.services.sagemaker.model.UpdateDomainRequest; + +import java.util.List; +import java.util.stream.Collectors; + +final class TranslatorForRequest { + + private TranslatorForRequest() {} + + /** + * Translates ResourceModel input to an aws sdk create resource request + * + * @param model resource model + * @return aws sdk create resource request + */ + static CreateDomainRequest translateToCreateRequest(final ResourceModel model) { + return CreateDomainRequest.builder() + .appNetworkAccessType(model.getAppNetworkAccessType()) + .authMode(model.getAuthMode()) + .defaultUserSettings(translateUserSettings(model.getDefaultUserSettings())) + .domainName(model.getDomainName()) + .kmsKeyId(model.getKmsKeyId()) + .subnetIds(model.getSubnetIds()) + .vpcId(model.getVpcId()) + .tags(Translator.streamOfOrEmpty(model.getTags()) + .map(t -> Tag.builder() + .key(t.getKey()) + .value(t.getValue()) + .build()) + .collect(Collectors.toList())) + .build(); + } + + /** + * Translates ResourceModel input to an aws sdk read resource request + * + * @param model resource model + * @return aws sdk read resource request + */ + static DescribeDomainRequest translateToReadRequest(final ResourceModel model) { + return DescribeDomainRequest.builder() + .domainId(model.getDomainId()) + .build(); + } + + /** + * Translates ResourceModel input to an aws sdk delete resource request + * + * @param model resource model + * @return aws sdk delete resource request + */ + static DeleteDomainRequest translateToDeleteRequest(final ResourceModel model) { + return DeleteDomainRequest.builder() + .domainId(model.getDomainId()) + .build(); + } + /** + * Translates ResourceModel input to an aws sdk update resource request + * + * @param model resource model + * @return update resource request + */ + static UpdateDomainRequest translateToUpdateRequest(final ResourceModel model) { + return UpdateDomainRequest.builder() + .domainId(model.getDomainId()) + .defaultUserSettings(translateUserSettings(model.getDefaultUserSettings())) + .build(); + } + + /** + * Translates ResourceModel input to an aws sdk list resource request + * + * @param nextToken token passed to the aws service describe resource request + * @return list resource request + */ + static ListDomainsRequest translateToListRequest(final String nextToken) { + return ListDomainsRequest.builder().nextToken(nextToken).build(); + } + + private static software.amazon.awssdk.services.sagemaker.model.UserSettings translateUserSettings( + UserSettings origin) { + if (origin == null) { + return null; + } + + return software.amazon.awssdk.services.sagemaker.model.UserSettings.builder() + .executionRole(origin.getExecutionRole()) + .jupyterServerAppSettings(translateJupyterServerAppSettings(origin.getJupyterServerAppSettings())) + .kernelGatewayAppSettings(translateKernelGatewayAppSettings(origin.getKernelGatewayAppSettings())) + .securityGroups(origin.getSecurityGroups()) + .sharingSettings(translateSharingSettings(origin.getSharingSettings())) + .build(); + } + + private static software.amazon.awssdk.services.sagemaker.model.JupyterServerAppSettings translateJupyterServerAppSettings( + JupyterServerAppSettings origin) { + if (origin == null) { + return null; + } + + return software.amazon.awssdk.services.sagemaker.model.JupyterServerAppSettings.builder() + .defaultResourceSpec(translateResourceSpec(origin.getDefaultResourceSpec())) + .build(); + } + + private static software.amazon.awssdk.services.sagemaker.model.KernelGatewayAppSettings translateKernelGatewayAppSettings( + KernelGatewayAppSettings origin) { + if (origin == null) { + return null; + } + + return software.amazon.awssdk.services.sagemaker.model.KernelGatewayAppSettings.builder() + .customImages(translateCustomImages(origin.getCustomImages())) + .defaultResourceSpec(translateResourceSpec(origin.getDefaultResourceSpec())) + .build(); + } + + private static software.amazon.awssdk.services.sagemaker.model.ResourceSpec translateResourceSpec( + ResourceSpec origin) { + if (origin == null) { + return null; + } + + return software.amazon.awssdk.services.sagemaker.model.ResourceSpec.builder() + .instanceType(origin.getInstanceType()) + .sageMakerImageArn(origin.getSageMakerImageArn()) + .sageMakerImageVersionArn(origin.getSageMakerImageVersionArn()) + .build(); + } + + private static List translateCustomImages( + List origin) { + if (origin == null) { + return null; + } + + return Translator.streamOfOrEmpty(origin) + .map(image -> software.amazon.awssdk.services.sagemaker.model.CustomImage.builder() + .appImageConfigName(image.getAppImageConfigName()) + .imageName(image.getImageName()) + .imageVersionNumber(image.getImageVersionNumber()) + .build()) + .collect(Collectors.toList()); + } + + private static software.amazon.awssdk.services.sagemaker.model.SharingSettings translateSharingSettings( + SharingSettings origin) { + if (origin == null) { + return null; + } + + return software.amazon.awssdk.services.sagemaker.model.SharingSettings.builder() + .notebookOutputOption(origin.getNotebookOutputOption()) + .s3KmsKeyId(origin.getS3KmsKeyId()) + .s3OutputPath(origin.getS3OutputPath()) + .build(); + } +} \ No newline at end of file diff --git a/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/TranslatorForResponse.java b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/TranslatorForResponse.java new file mode 100644 index 0000000..729b8bb --- /dev/null +++ b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/TranslatorForResponse.java @@ -0,0 +1,128 @@ +package software.amazon.sagemaker.domain; + +import software.amazon.awssdk.services.sagemaker.model.DescribeDomainResponse; +import software.amazon.awssdk.services.sagemaker.model.ListDomainsResponse; +import software.amazon.awssdk.services.sagemaker.model.ResourceSpec; + +import java.util.List; +import java.util.stream.Collectors; + +public class TranslatorForResponse { + + private TranslatorForResponse() {} + + /** + * Translates the AWS SDK read response into a native resource model. + * + * @param awsResponse the aws service describe resource response + * @return model resource model + */ + static ResourceModel translateFromReadResponse(final DescribeDomainResponse awsResponse) { + return ResourceModel.builder() + .domainArn(awsResponse.domainArn()) + .url(awsResponse.url()) + .appNetworkAccessType(awsResponse.appNetworkAccessTypeAsString()) + .authMode(awsResponse.authModeAsString()) + .defaultUserSettings(translateUserSettings(awsResponse.defaultUserSettings())) + .domainName(awsResponse.domainName()) + .kmsKeyId(awsResponse.kmsKeyId()) + .subnetIds(awsResponse.subnetIds()) + .vpcId(awsResponse.vpcId()) + .domainId(awsResponse.domainId()) + .homeEfsFileSystemId(awsResponse.homeEfsFileSystemId()) + .singleSignOnManagedApplicationInstanceId(awsResponse.singleSignOnManagedApplicationInstanceId()) + .build(); + } + + /** + * Translates the AWS SDK list response into a native resource model. + * + * @param awsResponse the aws service list resource response + * @return list of resource models + */ + static List translateFromListResponse(final ListDomainsResponse awsResponse) { + return Translator.streamOfOrEmpty(awsResponse.domains()) + .map(Domain -> ResourceModel.builder() + .domainId(Domain.domainId()) + .build()) + .collect(Collectors.toList()); + } + + private static UserSettings translateUserSettings( + software.amazon.awssdk.services.sagemaker.model.UserSettings origin) { + if (origin == null) { + return null; + } + + return UserSettings.builder() + .executionRole(origin.executionRole()) + .jupyterServerAppSettings(translateJupyterServerAppSettings(origin.jupyterServerAppSettings())) + .kernelGatewayAppSettings(translateKernelGatewayAppSettings(origin.kernelGatewayAppSettings())) + .securityGroups(origin.hasSecurityGroups() ? origin.securityGroups() : null) + .sharingSettings(translateSharingSettings(origin.sharingSettings())) + .build(); + } + + private static JupyterServerAppSettings translateJupyterServerAppSettings( + software.amazon.awssdk.services.sagemaker.model.JupyterServerAppSettings origin) { + if (origin == null) { + return null; + } + + return JupyterServerAppSettings.builder() + .defaultResourceSpec(translateResourceSpec(origin.defaultResourceSpec())) + .build(); + } + + private static KernelGatewayAppSettings translateKernelGatewayAppSettings( + software.amazon.awssdk.services.sagemaker.model.KernelGatewayAppSettings origin) { + if (origin == null) { + return null; + } + + return KernelGatewayAppSettings.builder() + .customImages(translateCustomImages(origin.customImages())) + .defaultResourceSpec(translateResourceSpec(origin.defaultResourceSpec())) + .build(); + } + + private static software.amazon.sagemaker.domain.ResourceSpec translateResourceSpec(ResourceSpec origin) { + if (origin == null) { + return null; + } + + return software.amazon.sagemaker.domain.ResourceSpec.builder() + .instanceType(origin.instanceTypeAsString()) + .sageMakerImageArn(origin.sageMakerImageArn()) + .sageMakerImageVersionArn(origin.sageMakerImageVersionArn()) + .build(); + } + + private static List translateCustomImages( + List origin) { + if (origin.isEmpty()) { + return null; + } + + return Translator.streamOfOrEmpty(origin) + .map(image -> software.amazon.sagemaker.domain.CustomImage.builder() + .appImageConfigName(image.appImageConfigName()) + .imageName(image.imageName()) + .imageVersionNumber(image.imageVersionNumber()) + .build()) + .collect(Collectors.toList()); + } + + private static SharingSettings translateSharingSettings( + software.amazon.awssdk.services.sagemaker.model.SharingSettings origin) { + if (origin == null) { + return null; + } + + return software.amazon.sagemaker.domain.SharingSettings.builder() + .notebookOutputOption(origin.notebookOutputOptionAsString()) + .s3KmsKeyId(origin.s3KmsKeyId()) + .s3OutputPath(origin.s3OutputPath()) + .build(); + } +} diff --git a/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/UpdateHandler.java b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/UpdateHandler.java new file mode 100644 index 0000000..ba38f5e --- /dev/null +++ b/aws-sagemaker-domain/src/main/java/software/amazon/sagemaker/domain/UpdateHandler.java @@ -0,0 +1,120 @@ +package software.amazon.sagemaker.domain; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.DescribeDomainResponse; +import software.amazon.awssdk.services.sagemaker.model.DomainStatus; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.awssdk.services.sagemaker.model.UpdateDomainRequest; +import software.amazon.awssdk.services.sagemaker.model.UpdateDomainResponse; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class UpdateHandler extends BaseHandlerStd { + + private static final String OPERATION = "AWS-SageMaker-Domain::Update"; + private Logger logger; + + @Override + protected ProgressEvent handleRequest( + AmazonWebServicesClientProxy proxy, + ResourceHandlerRequest request, + CallbackContext callbackContext, + ProxyClient proxyClient, + Logger logger + ) { + + this.logger = logger; + final ResourceModel model = request.getDesiredResourceState(); + + return ProgressEvent.progress(model, callbackContext) + .then(progress -> + proxy.initiate(OPERATION, proxyClient, model, callbackContext) + .translateToServiceRequest(TranslatorForRequest::translateToUpdateRequest) + .makeServiceCall(this::updateResource) + .stabilize(this::stabilizedOnUpdate) + .progress()) + .then(progress -> constructResourceModelFromResponse(model, proxyClient)); + } + + /** + * Client invocation of the update request through the proxyClient + * + * @param awsRequest aws service update resource request + * @param proxyClient the aws service client to make the call + * @return update resource response + */ + private UpdateDomainResponse updateResource( + final UpdateDomainRequest awsRequest, + final ProxyClient proxyClient + ) { + UpdateDomainResponse response = null; + try { + response = proxyClient.injectCredentialsAndInvokeV2(awsRequest, proxyClient.client()::updateDomain); + } catch (final AwsServiceException e) { + Translator.throwCfnException(Action.UPDATE.toString(), ResourceModel.TYPE_NAME, awsRequest.domainId(), e); + } + return response; + } + + /** + * This is used to ensure Domain resource has moved from Pending to any terminal state + * (e.g. Scheduled, Stopped). + * + * @param updateDomainRequest the aws service request to update a resource + * @param proxyClient the aws service client to make the call + * @return boolean state of stabilized or not + */ + private boolean stabilizedOnUpdate( + final UpdateDomainRequest updateDomainRequest, + final UpdateDomainResponse updateDomainResponse, + final ProxyClient proxyClient, + final ResourceModel model, + final CallbackContext callbackContext) { + + final DomainStatus DomainState = proxyClient.injectCredentialsAndInvokeV2( + TranslatorForRequest.translateToReadRequest(model), + proxyClient.client()::describeDomain).status(); + + switch (DomainState) { + case IN_SERVICE: + logger.log(String.format("%s [%s] has been stabilized with state %s during update operation.", + ResourceModel.TYPE_NAME, model.getPrimaryIdentifier(), DomainState)); + return true; + case UPDATING: + logger.log(String.format("%s [%s] is stabilizing during update.", ResourceModel.TYPE_NAME, model.getPrimaryIdentifier())); + return false; + default: + throw new CfnGeneralServiceException("Stabilizing during update of " + model.getPrimaryIdentifier()); + + } + } + + /** + * Build the Progress Event object from the describe response. + * + * @param model resource model + * @return progressEvent indicating success + */ + private ProgressEvent constructResourceModelFromResponse( + final ResourceModel model, + final ProxyClient proxyClient) { + DescribeDomainResponse response = null; + try { + response = proxyClient.injectCredentialsAndInvokeV2( + TranslatorForRequest.translateToReadRequest(model), + proxyClient.client()::describeDomain); + } catch (ResourceNotFoundException e) { + Translator.throwCfnException(Action.UPDATE.toString(), ResourceModel.TYPE_NAME, model.getPrimaryIdentifier().toString(), e); + } + + model.setDomainArn(response.domainArn()); + + return ProgressEvent.defaultSuccessHandler(model); + } +} diff --git a/aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/AbstractTestBase.java b/aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/AbstractTestBase.java new file mode 100644 index 0000000..3377055 --- /dev/null +++ b/aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/AbstractTestBase.java @@ -0,0 +1,70 @@ +package software.amazon.sagemaker.domain; + +import software.amazon.awssdk.awscore.AwsRequest; +import software.amazon.awssdk.awscore.AwsResponse; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.DomainDetails; +import software.amazon.awssdk.services.sagemaker.model.ListDomainsResponse; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Credentials; +import software.amazon.cloudformation.proxy.LoggerProxy; +import software.amazon.cloudformation.proxy.ProxyClient; + +import java.util.Collections; +import java.util.function.Function; + +public class AbstractTestBase { + protected static final String TEST_DOMAIN_NAME = "testDomainName"; + protected static final String TEST_DOMAIN_ID = "testDomainId"; + protected static final String TEST_DOMAIN_ARN = String.format("testAppArn/%s", TEST_DOMAIN_ID); + protected static final String TEST_URL = "testUrl"; + protected static final String TEST_STATUS = "testStatus"; + protected static final String TEST_AUTH_MODE = "testAuth"; + protected static final String TEST_APP_NETWORK_TYPE = "testAppNetwork"; + protected static final String TEST_VPC_ID = "testVpc"; + protected static final String TEST_SUBNET_ID = "testSubnet"; + protected static final String TEST_KMS = "testKMS"; + protected static final String TEST_EFS_ID = "testEfs"; + protected static final String TEST_SSO_MANAGED_APP = "testSSOManaged"; + protected static final String TEST_INSTANCE_TYPE = "testInstanceType"; + protected static final String TEST_IMAGE_ARN = "testImageArn"; + protected static final String TEST_IMAGE_VERSION_ARN = "testImageVersionArn"; + protected static final String TEST_NB_OUTPUT = "testNBOutput"; + protected static final String TEST_S3_KMS = "testS3KMS"; + protected static final String TEST_S3_OUTPUT = "testS3Output"; + protected static final String TEST_SECURITY_GROUP = "testSecGroup"; + protected static final String TEST_ROLE = "testRole"; + protected static final String TEST_IMAGE_NAME = "testImgName"; + protected static final String TEST_APP_IMAGE_CONFIG_NAME = "testAppImageConfigName"; + protected static final int TEST_IMAGE_VERSION_NUMBER = 7; + protected static final String TEST_ERROR_MESSAGE = "test error message"; + protected static final Credentials MOCK_CREDENTIALS; + protected static final LoggerProxy logger; + + static { + MOCK_CREDENTIALS = new Credentials("accessKey", "secretKey", "token"); + logger = new LoggerProxy(); + } + static ProxyClient MOCK_PROXY( + final AmazonWebServicesClientProxy proxy, + final SageMakerClient sagemakerClient) { + return new ProxyClient() { + @Override + public ResponseT + injectCredentialsAndInvokeV2(RequestT request, Function requestFunction) { + return proxy.injectCredentialsAndInvokeV2(request, requestFunction); + } + + @Override + public SageMakerClient client() { + return sagemakerClient; + } + }; + } + + static ResourceModel getPostCreationResourceModel() { + return ResourceModel.builder() + .domainId(TEST_DOMAIN_ID) + .build(); + } +} \ No newline at end of file diff --git a/aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/CreateHandlerTest.java b/aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/CreateHandlerTest.java new file mode 100644 index 0000000..92fc9f7 --- /dev/null +++ b/aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/CreateHandlerTest.java @@ -0,0 +1,372 @@ +package software.amazon.sagemaker.domain; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsErrorDetails; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.CreateDomainRequest; +import software.amazon.awssdk.services.sagemaker.model.CreateDomainResponse; +import software.amazon.awssdk.services.sagemaker.model.DescribeDomainRequest; +import software.amazon.awssdk.services.sagemaker.model.DescribeDomainResponse; +import software.amazon.awssdk.services.sagemaker.model.DomainStatus; +import software.amazon.awssdk.services.sagemaker.model.ResourceInUseException; +import software.amazon.awssdk.services.sagemaker.model.ResourceLimitExceededException; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnInvalidRequestException; +import software.amazon.cloudformation.exceptions.CfnNotStabilizedException; +import software.amazon.cloudformation.exceptions.CfnServiceLimitExceededException; +import software.amazon.cloudformation.exceptions.ResourceAlreadyExistsException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class CreateHandlerTest extends software.amazon.sagemaker.domain.AbstractTestBase { + + private final ResourceModel REQUEST_MODEL = ResourceModel.builder() + .domainName(TEST_DOMAIN_NAME) + .build(); + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + SageMakerClient sdkClient; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + sdkClient = mock(SageMakerClient.class); + proxyClient = MOCK_PROXY(proxy, sdkClient); + } + + @Test + public void testCreateHandler_SimpleSuccess() { + final DescribeDomainResponse describeResponse = DescribeDomainResponse.builder() + .domainName(TEST_DOMAIN_NAME) + .domainId(TEST_DOMAIN_ID) + .domainArn(TEST_DOMAIN_ARN) + .url(TEST_URL) + .homeEfsFileSystemId(TEST_EFS_ID) + .singleSignOnManagedApplicationInstanceId(TEST_SSO_MANAGED_APP) + .status(DomainStatus.IN_SERVICE) + .build(); + + final CreateDomainResponse createResponse = CreateDomainResponse.builder() + .domainArn(TEST_DOMAIN_ARN) + .build(); + + when(proxyClient.client().describeDomain(any(DescribeDomainRequest.class))) + .thenReturn(describeResponse); + when(proxyClient.client().createDomain(any(CreateDomainRequest.class))) + .thenReturn(createResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + final ProgressEvent response = invokeHandleRequest(request); + + ResourceModel expectedModelFromResponse = ResourceModel.builder() + .domainId(TEST_DOMAIN_ID) + .domainName(TEST_DOMAIN_NAME) + .domainArn(TEST_DOMAIN_ARN) + .url(TEST_URL) + .homeEfsFileSystemId(TEST_EFS_ID) + .singleSignOnManagedApplicationInstanceId(TEST_SSO_MANAGED_APP) + .build(); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isEqualTo(expectedModelFromResponse); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testCreateHandler_InvalidRequestDomainId() { + final ResourceModel invalidModel = ResourceModel.builder() + .domainId(TEST_DOMAIN_ID) + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(invalidModel) + .build(); + + Exception exception = assertThrows( CfnInvalidRequestException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.InvalidRequest.getMessage(), + "The following property 'DomainId' is not allowed to configured.")); + } + + @Test + public void testCreateHandler_InvalidRequestDomainArn() { + final ResourceModel invalidModel = ResourceModel.builder() + .domainArn(TEST_DOMAIN_ARN) + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(invalidModel) + .build(); + + Exception exception = assertThrows( CfnInvalidRequestException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.InvalidRequest.getMessage(), + "The following property 'DomainArn' is not allowed to configured.")); + } + + @Test + public void testCreateHandler_InvalidRequestUrl() { + final ResourceModel invalidModel = ResourceModel.builder() + .url(TEST_URL) + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(invalidModel) + .build(); + + Exception exception = assertThrows( CfnInvalidRequestException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.InvalidRequest.getMessage(), + "The following property 'Url' is not allowed to configured.")); + } + + @Test + public void testCreateHandler_InvalidRequestDomainEFSId() { + final ResourceModel invalidModel = ResourceModel.builder() + .homeEfsFileSystemId(TEST_EFS_ID) + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(invalidModel) + .build(); + + Exception exception = assertThrows( CfnInvalidRequestException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.InvalidRequest.getMessage(), + "The following property 'HomeEfsFileSystemId' is not allowed to configured.")); + } + + @Test + public void testCreateHandler_InvalidRequestDomainSSO_Id() { + final ResourceModel invalidModel = ResourceModel.builder() + .singleSignOnManagedApplicationInstanceId(TEST_SSO_MANAGED_APP) + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(invalidModel) + .build(); + + Exception exception = assertThrows( CfnInvalidRequestException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.InvalidRequest.getMessage(), + "The following property 'SingleSignOnManagedApplicationInstanceId' is not allowed to configured.")); + } + + @Test + public void testCreateHandler_ServiceInternalException() { + final AwsServiceException serviceInternalException = SageMakerException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(500) + .build(); + + when(proxyClient.client().createDomain(any(CreateDomainRequest.class))) + .thenThrow(serviceInternalException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows( CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.CREATE)); + } + + @Test + public void testCreateHandler_ResourceAlreadyExists_Fails() { + final ResourceInUseException resourceInUseException = ResourceInUseException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(400) + .build(); + + when(proxyClient.client().createDomain(any(CreateDomainRequest.class))) + .thenThrow(resourceInUseException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows( ResourceAlreadyExistsException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.AlreadyExists.getMessage(), + ResourceModel.TYPE_NAME, TEST_DOMAIN_NAME)); + } + + @Test + public void testCreateHandler_ResourceLimitExceededException() { + final ResourceLimitExceededException resourceLimitExceededException = ResourceLimitExceededException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(400) + .build(); + + when(proxyClient.client().createDomain(any(CreateDomainRequest.class))) + .thenThrow(resourceLimitExceededException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows(CfnServiceLimitExceededException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.ServiceLimitExceeded.getMessage(), + ResourceModel.TYPE_NAME, TEST_ERROR_MESSAGE)); + } + + @Test + public void testCreateHandler_ValidationFailure() { + final AwsErrorDetails awsErrorDetails = + AwsErrorDetails.builder().errorCode("ValidationError").errorMessage(TEST_ERROR_MESSAGE).build(); + + final AwsServiceException validationFailureException = SageMakerException.builder() + .awsErrorDetails(awsErrorDetails) + .build(); + + when(proxyClient.client().createDomain(any(CreateDomainRequest.class))) + .thenThrow(validationFailureException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows( CfnInvalidRequestException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.InvalidRequest.getMessage(), + TEST_ERROR_MESSAGE)); + } + + @Test + public void testCreateHandler_NoExceptionMessage() { + final AwsServiceException someException = SageMakerException.builder() + .statusCode(400) + .build(); + + when(proxyClient.client().createDomain(any(CreateDomainRequest.class))) + .thenThrow(someException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows( CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.CREATE)); + } + + @Test + public void testCreateHandler_VerifyStabilization_InService() { + final DescribeDomainResponse firstDescribeResponse = + DescribeDomainResponse.builder() + .status(DomainStatus.PENDING) + .build(); + + final DescribeDomainResponse secondDescribeResponse = + DescribeDomainResponse.builder() + .domainArn(TEST_DOMAIN_ARN) + .domainName(TEST_DOMAIN_NAME) + .domainId(TEST_DOMAIN_ID) + .url(TEST_URL) + .homeEfsFileSystemId(TEST_EFS_ID) + .singleSignOnManagedApplicationInstanceId(TEST_SSO_MANAGED_APP) + .status(DomainStatus.IN_SERVICE) + .build(); + + final CreateDomainResponse createDomainResponse = CreateDomainResponse.builder() + .domainArn(TEST_DOMAIN_ARN) + .build(); + + when(proxyClient.client().describeDomain(any(DescribeDomainRequest.class))) + .thenReturn(firstDescribeResponse).thenReturn(secondDescribeResponse); + when(proxyClient.client().createDomain(any(CreateDomainRequest.class))) + .thenReturn(createDomainResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + final ProgressEvent response = invokeHandleRequest(request); + + ResourceModel expectedModelFromResponse = ResourceModel.builder() + .domainArn(TEST_DOMAIN_ARN) + .domainName(TEST_DOMAIN_NAME) + .domainId(TEST_DOMAIN_ID) + .url(TEST_URL) + .homeEfsFileSystemId(TEST_EFS_ID) + .singleSignOnManagedApplicationInstanceId(TEST_SSO_MANAGED_APP) + .build(); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isEqualTo(expectedModelFromResponse); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testCreateHandler_VerifyStabilization_Failed() { + final DescribeDomainResponse firstDescribeResponse = + DescribeDomainResponse.builder() + .status(DomainStatus.PENDING) + .build(); + + final DescribeDomainResponse secondDescribeResponse = + DescribeDomainResponse.builder() + .status(DomainStatus.FAILED) + .build(); + + final CreateDomainResponse createDomainResponse = CreateDomainResponse.builder() + .domainArn(TEST_DOMAIN_ARN) + .build(); + + when(proxyClient.client().describeDomain(any(DescribeDomainRequest.class))) + .thenReturn(firstDescribeResponse).thenReturn(secondDescribeResponse); + when(proxyClient.client().createDomain(any(CreateDomainRequest.class))) + .thenReturn(createDomainResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows(CfnNotStabilizedException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()). + isEqualTo(String.format(HandlerErrorCode.NotStabilized.getMessage(), ResourceModel.TYPE_NAME, TEST_DOMAIN_NAME)); + } + + private ProgressEvent invokeHandleRequest(ResourceHandlerRequest request) { + final CreateHandler handler = new CreateHandler(); + return handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + } +} \ No newline at end of file diff --git a/aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/DeleteHandlerTest.java b/aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/DeleteHandlerTest.java new file mode 100644 index 0000000..de15840 --- /dev/null +++ b/aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/DeleteHandlerTest.java @@ -0,0 +1,191 @@ +package software.amazon.sagemaker.domain; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.DeleteDomainRequest; +import software.amazon.awssdk.services.sagemaker.model.DeleteDomainResponse; +import software.amazon.awssdk.services.sagemaker.model.DescribeDomainRequest; +import software.amazon.awssdk.services.sagemaker.model.DescribeDomainResponse; +import software.amazon.awssdk.services.sagemaker.model.DomainStatus; +import software.amazon.awssdk.services.sagemaker.model.ResourceInUseException; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.exceptions.CfnNotStabilizedException; +import software.amazon.cloudformation.exceptions.CfnResourceConflictException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DeleteHandlerTest extends software.amazon.sagemaker.domain.AbstractTestBase { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + SageMakerClient sdkClient; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + sdkClient = mock(SageMakerClient.class); + proxyClient = MOCK_PROXY(proxy, sdkClient); + } + + @Test + public void testDeleteHandler_SimpleSuccess() { + final DeleteDomainResponse deleteDomainResponse = DeleteDomainResponse.builder().build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getPostCreationResourceModel()) + .build(); + + when(proxyClient.client().describeDomain(any(DescribeDomainRequest.class))) + .thenThrow(ResourceNotFoundException.class); + when(proxyClient.client().deleteDomain(any(DeleteDomainRequest.class))) + .thenReturn(deleteDomainResponse); + + final ProgressEvent response = invokeHandleRequest(request); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo((OperationStatus.SUCCESS)); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + assertThat(response.getResourceModel()).isNull(); + } + + @Test + public void testDeleteHandler_ServiceInternalException() { + final AwsServiceException serviceInternalException = SageMakerException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(500) + .build(); + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getPostCreationResourceModel()) + .build(); + + when(proxyClient.client().deleteDomain(any(DeleteDomainRequest.class))) + .thenThrow(serviceInternalException); + + Exception exception = assertThrows(CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.DELETE)); + } + + @Test + public void testDeleteHandler_ResourceInUse_Fails() { + when(proxyClient.client().deleteDomain(any(DeleteDomainRequest.class))) + .thenThrow(ResourceInUseException.class); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getPostCreationResourceModel()) + .build(); + + Exception exception = assertThrows(CfnResourceConflictException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.ResourceConflict.getMessage(), + ResourceModel.TYPE_NAME, TEST_DOMAIN_ID, null)); + } + + @Test + public void testDeleteHandler_ResourceDoesNotExists_Fails() { + when(proxyClient.client().deleteDomain(any(DeleteDomainRequest.class))) + .thenThrow(ResourceNotFoundException.class); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getPostCreationResourceModel()) + .build(); + + Exception exception = assertThrows(CfnNotFoundException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.NotFound.getMessage(), + ResourceModel.TYPE_NAME, TEST_DOMAIN_ID)); + } + + @Test + public void testDeleteHandler_VerifyStabilization_SuccessfulDelete() { + final DescribeDomainResponse firstDescribeResponse = + DescribeDomainResponse.builder() + .domainName(TEST_DOMAIN_NAME) + .domainId(TEST_DOMAIN_ID) + .status(DomainStatus.DELETING) + .build(); + + final DeleteDomainResponse deleteDomainResponse = DeleteDomainResponse.builder() + .build(); + + when(proxyClient.client().describeDomain(any(DescribeDomainRequest.class))) + .thenReturn(firstDescribeResponse).thenThrow(ResourceNotFoundException.class); + when(proxyClient.client().deleteDomain(any(DeleteDomainRequest.class))) + .thenReturn(deleteDomainResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getPostCreationResourceModel()) + .build(); + + final ProgressEvent response = invokeHandleRequest(request); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testDeleteHandler_VerifyStabilization_ResourceNotDeleted() { + final DescribeDomainResponse firstDescribeResponse =DescribeDomainResponse.builder() + .status(DomainStatus.PENDING) + .build(); + + final DescribeDomainResponse secondDescribeResponse = DescribeDomainResponse.builder() + .status(DomainStatus.FAILED) + .build(); + + final DeleteDomainResponse deleteDomainResponse = DeleteDomainResponse.builder() + .build(); + + when(proxyClient.client().describeDomain(any(DescribeDomainRequest.class))) + .thenReturn(firstDescribeResponse).thenReturn(secondDescribeResponse); + when(proxyClient.client().deleteDomain(any(DeleteDomainRequest.class))) + .thenReturn(deleteDomainResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getPostCreationResourceModel()) + .build(); + + Exception exception = assertThrows( CfnNotStabilizedException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.NotStabilized.getMessage(), + ResourceModel.TYPE_NAME, getPostCreationResourceModel().getPrimaryIdentifier())); + } + + private ProgressEvent invokeHandleRequest(ResourceHandlerRequest request) { + final DeleteHandler handler = new DeleteHandler(); + return handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + } +} diff --git a/aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/ListHandlerTest.java b/aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/ListHandlerTest.java new file mode 100644 index 0000000..115110f --- /dev/null +++ b/aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/ListHandlerTest.java @@ -0,0 +1,139 @@ +package software.amazon.sagemaker.domain; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsErrorDetails; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.DomainDetails; +import software.amazon.awssdk.services.sagemaker.model.ListDomainsRequest; +import software.amazon.awssdk.services.sagemaker.model.ListDomainsResponse; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.cloudformation.exceptions.CfnServiceInternalErrorException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class ListHandlerTest extends software.amazon.sagemaker.domain.AbstractTestBase { + + private static final String OPERATION = "SageMaker::ListDomains"; + public static final String TEST_TOKEN = "testToken"; + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + SageMakerClient sdkClient; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + sdkClient = mock(SageMakerClient.class); + proxyClient = MOCK_PROXY(proxy, sdkClient); + } + + @Test + public void testListHandler_SimpleSuccess() { + final DomainDetails domain = DomainDetails.builder() + .domainId(TEST_DOMAIN_ID) + .build(); + + final ListDomainsResponse listResponse = ListDomainsResponse.builder() + .domains(domain) + .nextToken(TEST_TOKEN) + .build(); + + when(proxyClient.client().listDomains(any(ListDomainsRequest.class))) + .thenReturn(listResponse); + + final ResourceModel expectedResourceModel = ResourceModel.builder() + .domainId(TEST_DOMAIN_ID) + .build(); + + List expectedModels = new ArrayList(); + expectedModels.add(expectedResourceModel); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getPostCreationResourceModel()) + .build(); + final ProgressEvent response = invokeHandleRequest(request); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isNull(); + assertThat(response.getResourceModels()).isEqualTo(expectedModels); + assertThat(response.getNextToken()).isEqualTo(TEST_TOKEN); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testListHandler_SimpleSuccess_NoDomainsExist() { + final ListDomainsResponse listResponse = ListDomainsResponse.builder() + .domains(Collections.emptyList()) + .nextToken(null) + .build(); + + when(proxyClient.client().listDomains(any(ListDomainsRequest.class))) + .thenReturn(listResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getPostCreationResourceModel()) + .build(); + final ProgressEvent response = invokeHandleRequest(request); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isNull(); + assertThat(response.getResourceModels()).isEqualTo(Collections.emptyList()); + assertThat(response.getNextToken()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testListHandler_ServiceInternalException() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("InternalError").errorMessage(OPERATION).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + when(proxyClient.client().listDomains(any(ListDomainsRequest.class))) + .thenThrow(ex); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getPostCreationResourceModel()) + .build(); + + Exception exception = assertThrows(CfnServiceInternalErrorException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.ServiceInternalError.getMessage(), + OPERATION)); + } + + private ProgressEvent invokeHandleRequest(ResourceHandlerRequest request) { + final ListHandler handler = new ListHandler(); + return handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + } +} \ No newline at end of file diff --git a/aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/ReadHandlerTest.java b/aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/ReadHandlerTest.java new file mode 100644 index 0000000..d92a046 --- /dev/null +++ b/aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/ReadHandlerTest.java @@ -0,0 +1,239 @@ +package software.amazon.sagemaker.domain; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.CustomImage; +import software.amazon.awssdk.services.sagemaker.model.DescribeDomainRequest; +import software.amazon.awssdk.services.sagemaker.model.DescribeDomainResponse; +import software.amazon.awssdk.services.sagemaker.model.JupyterServerAppSettings; +import software.amazon.awssdk.services.sagemaker.model.KernelGatewayAppSettings; +import software.amazon.awssdk.services.sagemaker.model.ListDomainsRequest; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.awssdk.services.sagemaker.model.ResourceSpec; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.awssdk.services.sagemaker.model.SharingSettings; +import software.amazon.awssdk.services.sagemaker.model.UserSettings; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.time.Duration; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class ReadHandlerTest extends software.amazon.sagemaker.domain.AbstractTestBase { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + SageMakerClient sdkClient; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + sdkClient = mock(SageMakerClient.class); + proxyClient = MOCK_PROXY(proxy, sdkClient); + } + + @Test + public void testReadHandler_SimpleSuccess() { + final SharingSettings sharingSettings = SharingSettings.builder() + .notebookOutputOption(TEST_NB_OUTPUT) + .s3KmsKeyId(TEST_S3_KMS) + .s3OutputPath(TEST_S3_OUTPUT) + .build(); + + final CustomImage customImage = CustomImage.builder() + .imageName(TEST_IMAGE_NAME) + .imageVersionNumber(TEST_IMAGE_VERSION_NUMBER) + .appImageConfigName(TEST_APP_IMAGE_CONFIG_NAME) + .build(); + + final ResourceSpec resourceSpec = ResourceSpec.builder() + .instanceType(TEST_INSTANCE_TYPE) + .sageMakerImageArn(TEST_IMAGE_ARN) + .sageMakerImageVersionArn(TEST_IMAGE_VERSION_ARN) + .build(); + + final KernelGatewayAppSettings kernelGatewayAppSettings = KernelGatewayAppSettings.builder() + .customImages(customImage) + .defaultResourceSpec(resourceSpec) + .build(); + + final JupyterServerAppSettings jupyterServerAppSettings = JupyterServerAppSettings.builder() + .defaultResourceSpec(resourceSpec) + .build(); + + final UserSettings userSettings = UserSettings.builder() + .sharingSettings(sharingSettings) + .securityGroups(TEST_SECURITY_GROUP) + .executionRole(TEST_ROLE) + .kernelGatewayAppSettings(kernelGatewayAppSettings) + .jupyterServerAppSettings(jupyterServerAppSettings) + .build(); + + final DescribeDomainResponse describeResponse = DescribeDomainResponse.builder() + .domainArn(TEST_DOMAIN_ARN) + .domainName(TEST_DOMAIN_NAME) + .domainId(TEST_DOMAIN_ID) + .appNetworkAccessType(TEST_APP_NETWORK_TYPE) + .authMode(TEST_AUTH_MODE) + .defaultUserSettings(userSettings) + .homeEfsFileSystemId(TEST_EFS_ID) + .singleSignOnManagedApplicationInstanceId(TEST_SSO_MANAGED_APP) + .subnetIds(TEST_SUBNET_ID) + .status(TEST_STATUS) + .build(); + + when(proxyClient.client().describeDomain(any(DescribeDomainRequest.class))) + .thenReturn(describeResponse); + + final software.amazon.sagemaker.domain.ResourceSpec expectedResourceSpec = + software.amazon.sagemaker.domain.ResourceSpec.builder() + .instanceType(TEST_INSTANCE_TYPE) + .sageMakerImageArn(TEST_IMAGE_ARN) + .sageMakerImageVersionArn(TEST_IMAGE_VERSION_ARN) + .build(); + + final software.amazon.sagemaker.domain.SharingSettings expectedSharingSettings = + software.amazon.sagemaker.domain.SharingSettings.builder() + .notebookOutputOption(TEST_NB_OUTPUT) + .s3KmsKeyId(TEST_S3_KMS) + .s3OutputPath(TEST_S3_OUTPUT) + .build(); + + final software.amazon.sagemaker.domain.CustomImage expectedCustomImage = + software.amazon.sagemaker.domain.CustomImage.builder() + .imageName(TEST_IMAGE_NAME) + .imageVersionNumber(TEST_IMAGE_VERSION_NUMBER) + .appImageConfigName(TEST_APP_IMAGE_CONFIG_NAME) + .build(); + + final software.amazon.sagemaker.domain.KernelGatewayAppSettings expectedKernelGatewayAppSettings = + software.amazon.sagemaker.domain.KernelGatewayAppSettings.builder() + .customImages(Collections.singletonList(expectedCustomImage)) + .defaultResourceSpec(expectedResourceSpec) + .build(); + + final software.amazon.sagemaker.domain.JupyterServerAppSettings expectedJupyterServerAppSettings = + software.amazon.sagemaker.domain.JupyterServerAppSettings.builder() + .defaultResourceSpec(expectedResourceSpec) + .build(); + + final software.amazon.sagemaker.domain.UserSettings expectedUserSettings = + software.amazon.sagemaker.domain.UserSettings.builder() + .sharingSettings(expectedSharingSettings) + .securityGroups(Collections.singletonList(TEST_SECURITY_GROUP)) + .executionRole(TEST_ROLE) + .kernelGatewayAppSettings(expectedKernelGatewayAppSettings) + .jupyterServerAppSettings(expectedJupyterServerAppSettings) + .build(); + + final ResourceModel expectedResourceModel = ResourceModel.builder() + .domainArn(TEST_DOMAIN_ARN) + .domainName(TEST_DOMAIN_NAME) + .domainId(TEST_DOMAIN_ID) + .appNetworkAccessType(TEST_APP_NETWORK_TYPE) + .authMode(TEST_AUTH_MODE) + .defaultUserSettings(expectedUserSettings) + .subnetIds(Collections.singletonList(TEST_SUBNET_ID)) + .homeEfsFileSystemId(TEST_EFS_ID) + .singleSignOnManagedApplicationInstanceId(TEST_SSO_MANAGED_APP) + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getPostCreationResourceModel()) + .build(); + final ProgressEvent response = invokeHandleRequest(request); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isEqualTo(expectedResourceModel); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + verify(proxyClient.client()).describeDomain(any(DescribeDomainRequest.class)); + } + + @Test + public void testReadHandler_ServiceInternalException() { + final AwsServiceException serviceInternalException = SageMakerException.builder() + .message("test error message") + .statusCode(500) + .build(); + + when(proxyClient.client().describeDomain(any(DescribeDomainRequest.class))) + .thenThrow(serviceInternalException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getPostCreationResourceModel()) + .build(); + + Exception exception = assertThrows( CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.READ)); + } + + @Test + public void testReadHandler_DomainDoesNotExist_Fails() { + final AwsServiceException resourceNotFoundException = AwsServiceException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(400) + .build(); + + when(proxyClient.client().describeDomain(any(DescribeDomainRequest.class))) + .thenThrow(resourceNotFoundException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getPostCreationResourceModel()) + .build(); + + Exception exception = assertThrows( CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.READ)); + } + + @Test + public void testReadHandler_ResourceNotFoundException() { + when(proxyClient.client().describeDomain(any(DescribeDomainRequest.class))) + .thenThrow(ResourceNotFoundException.class); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getPostCreationResourceModel()) + .build(); + + Exception exception = assertThrows(CfnNotFoundException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.NotFound.getMessage(), + ResourceModel.TYPE_NAME, TEST_DOMAIN_ID)); + } + + private ProgressEvent invokeHandleRequest(ResourceHandlerRequest request) { + final ReadHandler handler = new ReadHandler(); + return handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + } +} \ No newline at end of file diff --git a/aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/TranslatorTest.java b/aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/TranslatorTest.java new file mode 100644 index 0000000..0ad020e --- /dev/null +++ b/aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/TranslatorTest.java @@ -0,0 +1,167 @@ +package software.amazon.sagemaker.domain; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.awscore.exception.AwsErrorDetails; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.model.ResourceLimitExceededException; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.cloudformation.exceptions.CfnAccessDeniedException; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnInvalidRequestException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.exceptions.CfnServiceInternalErrorException; +import software.amazon.cloudformation.exceptions.CfnServiceLimitExceededException; +import software.amazon.cloudformation.exceptions.CfnThrottlingException; +import software.amazon.cloudformation.proxy.HandlerErrorCode; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class TranslatorTest { + + public static final String TEST_OPERATION = "someOperation"; + public static final String RESOURCE_TYPE = "someResource"; + public static final String RESOURCE_NAME = "someType"; + public static final String ERROR_MESSAGE = "someError"; + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_UnauthorizedOperation() { + AwsErrorDetails errorDetails = + AwsErrorDetails.builder().errorCode("UnauthorizedOperation").errorMessage(ERROR_MESSAGE).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnAccessDeniedException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.AccessDenied.getMessage(), + ERROR_MESSAGE)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_InvalidParameter() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("InvalidParameter").build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnInvalidRequestException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_InvalidParameterValue() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("InvalidParameterValue").build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnInvalidRequestException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ValidationError() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("ValidationError").build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnInvalidRequestException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ValidationException() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("ValidationException").build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnInvalidRequestException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_InternalError() { + AwsErrorDetails errorDetails = + AwsErrorDetails.builder().errorCode("InternalError").errorMessage(ERROR_MESSAGE).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnServiceInternalErrorException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.ServiceInternalError.getMessage(), + ERROR_MESSAGE)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ServiceUnavailable() { + AwsErrorDetails errorDetails = + AwsErrorDetails.builder().errorCode("ServiceUnavailable").errorMessage(ERROR_MESSAGE).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnServiceInternalErrorException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.ServiceInternalError.getMessage(), + ERROR_MESSAGE)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ResourceLimitExceeded() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("ResourceLimitExceeded").build(); + AwsServiceException ex = ResourceLimitExceededException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnServiceLimitExceededException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ResourceNotFound() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("ResourceNotFound").build(); + AwsServiceException ex = ResourceNotFoundException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnNotFoundException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ThrottlingException() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("ThrottlingException").errorMessage(ERROR_MESSAGE).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnThrottlingException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.Throttling.getMessage(), + ERROR_MESSAGE)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_UnknownException() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("Unknown").errorMessage(ERROR_MESSAGE).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnGeneralServiceException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + ERROR_MESSAGE)); + } + + @Test + public void testGetHandlerError_NoErrorCode() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnGeneralServiceException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + TEST_OPERATION)); + } + + @Test + public void testGetHandlerError_NoErrorDetails() { + AwsServiceException ex = SageMakerException.builder().build(); + + Exception exception = assertThrows(CfnGeneralServiceException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + TEST_OPERATION)); + } +} \ No newline at end of file diff --git a/aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/UpdateHandlerTest.java b/aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/UpdateHandlerTest.java new file mode 100644 index 0000000..c0600ec --- /dev/null +++ b/aws-sagemaker-domain/src/test/java/software/amazon/sagemaker/domain/UpdateHandlerTest.java @@ -0,0 +1,216 @@ +package software.amazon.sagemaker.domain; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.DescribeDomainRequest; +import software.amazon.awssdk.services.sagemaker.model.DescribeDomainResponse; +import software.amazon.awssdk.services.sagemaker.model.DomainStatus; +import software.amazon.awssdk.services.sagemaker.model.ListDomainsRequest; +import software.amazon.awssdk.services.sagemaker.model.ResourceLimitExceededException; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.awssdk.services.sagemaker.model.UpdateDomainRequest; +import software.amazon.awssdk.services.sagemaker.model.UpdateDomainResponse; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.exceptions.CfnServiceLimitExceededException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.time.Duration; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class UpdateHandlerTest extends software.amazon.sagemaker.domain.AbstractTestBase { + + private static final String OPERATION = "SageMaker::UpdateDomain"; + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + SageMakerClient sdkClient; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + sdkClient = mock(SageMakerClient.class); + proxyClient = MOCK_PROXY(proxy, sdkClient); + } + + @Test + public void testUpdateHandler_SimpleSuccess() { + final DescribeDomainResponse describeDomainResponse = + DescribeDomainResponse.builder() + .domainArn(TEST_DOMAIN_ARN) + .status(DomainStatus.IN_SERVICE) + .build(); + + final UpdateDomainResponse updateDomainResponse = UpdateDomainResponse.builder().build(); + + when(proxyClient.client().describeDomain(any(DescribeDomainRequest.class))) + .thenReturn(describeDomainResponse); + when(proxyClient.client().updateDomain(any(UpdateDomainRequest.class))) + .thenReturn(updateDomainResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getPostCreationResourceModel()) + .build(); + final ProgressEvent response = invokeHandleRequest(request); + + ResourceModel expectedModelFromResponse = ResourceModel.builder() + .domainId(TEST_DOMAIN_ID) + .domainArn(TEST_DOMAIN_ARN) + .build(); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isEqualTo(expectedModelFromResponse); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testUpdateHandler_ServiceInternalException() { + final AwsServiceException serviceInternalException = SageMakerException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(500) + .build(); + + when(proxyClient.client().updateDomain(any(UpdateDomainRequest.class))) + .thenThrow(serviceInternalException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getPostCreationResourceModel()) + .build(); + + Exception exception = assertThrows(CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.UPDATE.toString())); + } + + @Test + public void testUpdateHandler_ResourceNotFoundException() { + when(proxyClient.client().updateDomain(any(UpdateDomainRequest.class))) + .thenThrow(ResourceNotFoundException.class); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getPostCreationResourceModel()) + .build(); + + Exception exception = assertThrows(CfnNotFoundException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.NotFound.getMessage(), + ResourceModel.TYPE_NAME, TEST_DOMAIN_ID)); + } + + @Test + public void testUpdateHandler_ResourceLimitExceededException() { + when(proxyClient.client().updateDomain(any(UpdateDomainRequest.class))) + .thenThrow(ResourceLimitExceededException.class); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getPostCreationResourceModel()) + .build(); + + Exception exception = assertThrows(CfnServiceLimitExceededException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.ServiceLimitExceeded.getMessage(), + ResourceModel.TYPE_NAME, null)); + } + + @Test + public void testUpdateHandler_VerifyStabilization_SuccessfulUpdate() { + final DescribeDomainResponse firstDescribeResponse = + DescribeDomainResponse.builder() + .status(DomainStatus.UPDATING) + .build(); + + final DescribeDomainResponse secondDescribeResponse = + DescribeDomainResponse.builder() + .domainArn(TEST_DOMAIN_ARN) + .status(DomainStatus.IN_SERVICE) + .build(); + + final UpdateDomainResponse updateDomainResponse = UpdateDomainResponse.builder().build(); + + when(proxyClient.client().describeDomain(any(DescribeDomainRequest.class))) + .thenReturn(firstDescribeResponse).thenReturn(secondDescribeResponse); + when(proxyClient.client().updateDomain(any(UpdateDomainRequest.class))) + .thenReturn(updateDomainResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getPostCreationResourceModel()) + .build(); + + final ProgressEvent response = invokeHandleRequest(request); + + ResourceModel expectedModelFromResponse = ResourceModel.builder() + .domainId(TEST_DOMAIN_ID) + .domainArn(TEST_DOMAIN_ARN) + .build(); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isEqualTo(expectedModelFromResponse); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testUpdateHandler_VerifyStabilization_Failed() { + final DescribeDomainResponse firstDescribeResponse = + DescribeDomainResponse.builder() + .status(DomainStatus.UPDATING) + .build(); + + final DescribeDomainResponse secondDescribeResponse = + DescribeDomainResponse.builder() + .status(DomainStatus.UPDATE_FAILED) + .build(); + + final UpdateDomainResponse updateDomainResponse = UpdateDomainResponse.builder() + .build(); + + when(proxyClient.client().describeDomain(any(DescribeDomainRequest.class))) + .thenReturn(firstDescribeResponse).thenReturn(secondDescribeResponse); + when(proxyClient.client().updateDomain(any(UpdateDomainRequest.class))) + .thenReturn(updateDomainResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getPostCreationResourceModel()) + .build(); + + Exception exception = assertThrows( CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + "Stabilizing during update of " + request.getDesiredResourceState().getPrimaryIdentifier())); + } + + private ProgressEvent invokeHandleRequest(ResourceHandlerRequest request) { + final UpdateHandler handler = new UpdateHandler(); + return handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + } +} \ No newline at end of file diff --git a/aws-sagemaker-domain/template.yml b/aws-sagemaker-domain/template.yml new file mode 100644 index 0000000..05ce1de --- /dev/null +++ b/aws-sagemaker-domain/template.yml @@ -0,0 +1,24 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Description: AWS SAM template for the AWS::SageMaker::Domain resource type + +Globals: + Function: + Timeout: 180 # docker start-up times can be long for SAM CLI + MemorySize: 256 + +Resources: + TypeFunction: + Type: AWS::Serverless::Function + Properties: + Handler: software.amazon.sagemaker.domain.HandlerWrapper::handleRequest + Runtime: java8 + CodeUri: ./target/aws-sagemaker-domain-handler-1.0-SNAPSHOT.jar + + TestEntrypoint: + Type: AWS::Serverless::Function + Properties: + Handler: software.amazon.sagemaker.domain.HandlerWrapper::testEntrypoint + Runtime: java8 + CodeUri: ./target/aws-sagemaker-domain-handler-1.0-SNAPSHOT.jar + diff --git a/aws-sagemaker-userprofile/.rpdk-config b/aws-sagemaker-userprofile/.rpdk-config new file mode 100644 index 0000000..0e79e1d --- /dev/null +++ b/aws-sagemaker-userprofile/.rpdk-config @@ -0,0 +1,17 @@ +{ + "typeName": "AWS::SageMaker::UserProfile", + "language": "java", + "runtime": "java8", + "entrypoint": "software.amazon.sagemaker.userprofile.HandlerWrapper::handleRequest", + "testEntrypoint": "software.amazon.sagemaker.userprofile.HandlerWrapper::testEntrypoint", + "settings": { + "namespace": [ + "software", + "amazon", + "sagemaker", + "userprofile" + ], + "codegen_template_path": "default", + "protocolVersion": "2.0.0" + } +} diff --git a/aws-sagemaker-userprofile/README.md b/aws-sagemaker-userprofile/README.md new file mode 100644 index 0000000..215698c --- /dev/null +++ b/aws-sagemaker-userprofile/README.md @@ -0,0 +1,12 @@ +# AWS::SageMaker::UserProfile + +Congratulations on starting development! Next steps: + +1. Write the JSON schema describing your resource, `aws-sagemaker-userprofile.json` +1. Implement your resource handlers. + +The RPDK will automatically generate the correct resource model from the schema whenever the project is built via Maven. You can also do this manually with the following command: `cfn generate`. + +> Please don't modify files under `target/generated-sources/rpdk`, as they will be automatically overwritten. + +The code uses [Lombok](https://projectlombok.org/), and [you may have to install IDE integrations](https://projectlombok.org/setup/overview) to enable auto-complete for Lombok-annotated classes. diff --git a/aws-sagemaker-userprofile/aws-sagemaker-userprofile.json b/aws-sagemaker-userprofile/aws-sagemaker-userprofile.json new file mode 100644 index 0000000..37ed37e --- /dev/null +++ b/aws-sagemaker-userprofile/aws-sagemaker-userprofile.json @@ -0,0 +1,312 @@ +{ + "typeName": "AWS::SageMaker::UserProfile", + "description": "Resource Type definition for AWS::SageMaker::UserProfile", + "additionalProperties": false, + "properties": { + "UserProfileArn": { + "type": "string", + "description": "The user profile Amazon Resource Name (ARN).", + "maxLength": 256, + "pattern": "arn:aws[a-z\\-]*:sagemaker:[a-z0-9\\-]*:[0-9]{12}:user-profile/.*" + }, + "DomainId": { + "type": "string", + "description": "The ID of the associated Domain.", + "minLength": 1, + "maxLength": 63 + }, + "SingleSignOnUserIdentifier": { + "type": "string", + "description": "A specifier for the type of value specified in SingleSignOnUserValue. Currently, the only supported value is \"UserName\". If the Domain's AuthMode is SSO, this field is required. If the Domain's AuthMode is not SSO, this field cannot be specified.", + "pattern": "UserName" + }, + "SingleSignOnUserValue": { + "type": "string", + "description": "The username of the associated AWS Single Sign-On User for this UserProfile. If the Domain's AuthMode is SSO, this field is required, and must match a valid username of a user in your directory. If the Domain's AuthMode is not SSO, this field cannot be specified.", + "minLength": 1, + "maxLength": 256 + }, + "UserProfileName": { + "type": "string", + "description": "A name for the UserProfile.", + "minLength": 1, + "maxLength": 63 + }, + "UserSettings": { + "$ref": "#/definitions/UserSettings", + "description": "A collection of settings.", + "uniqueItems": false, + "minItems": 0, + "maxItems": 50 + }, + "Tags": { + "type": "array", + "description": "A list of tags to apply to the user profile.", + "uniqueItems": false, + "minItems": 0, + "maxItems": 50, + "items": { + "$ref": "#/definitions/Tag" + } + } + }, + "definitions": { + "UserSettings": { + "type": "object", + "description": "A collection of settings that apply to users of Amazon SageMaker Studio. These settings are specified when the CreateUserProfile API is called, and as DefaultUserSettings when the CreateDomain API is called.", + "additionalProperties": false, + "properties": { + "ExecutionRole": { + "type": "string", + "description": "The user profile Amazon Resource Name (ARN).", + "minLength": 20, + "maxLength": 2048, + "pattern": "^arn:aws[a-z\\-]*:iam::\\d{12}:role/?[a-zA-Z_0-9+=,.@\\-_/]+$" + }, + "JupyterServerAppSettings": { + "$ref": "#/definitions/JupyterServerAppSettings", + "description": "The Jupyter server's app settings." + }, + "KernelGatewayAppSettings": { + "$ref": "#/definitions/KernelGatewayAppSettings", + "description": "The kernel gateway app settings." + }, + "SecurityGroups": { + "type": "array", + "description": "The security groups for the Amazon Virtual Private Cloud (VPC) that Studio uses for communication.", + "uniqueItems": false, + "minItems": 0, + "maxItems": 5, + "items": { + "type": "string", + "maxLength": 32, + "pattern": "[-0-9a-zA-Z]+" + } + }, + "SharingSettings": { + "$ref": "#/definitions/SharingSettings", + "description": "The sharing settings." + } + } + }, + "JupyterServerAppSettings": { + "type": "object", + "description": "The JupyterServer app settings.", + "additionalProperties": false, + "properties": { + "DefaultResourceSpec": { + "$ref": "#/definitions/ResourceSpec" + } + } + }, + "ResourceSpec": { + "type": "object", + "additionalProperties": false, + "properties": { + "InstanceType": { + "type": "string", + "description": "The instance type that the image version runs on.", + "enum": [ + "system", + "ml.t3.micro", + "ml.t3.small", + "ml.t3.medium", + "ml.t3.large", + "ml.t3.xlarge", + "ml.t3.2xlarge", + "ml.m5.large", + "ml.m5.xlarge", + "ml.m5.2xlarge", + "ml.m5.4xlarge", + "ml.m5.8xlarge", + "ml.m5.12xlarge", + "ml.m5.16xlarge", + "ml.m5.24xlarge", + "ml.c5.large", + "ml.c5.xlarge", + "ml.c5.2xlarge", + "ml.c5.4xlarge", + "ml.c5.9xlarge", + "ml.c5.12xlarge", + "ml.c5.18xlarge", + "ml.c5.24xlarge", + "ml.p3.2xlarge", + "ml.p3.8xlarge", + "ml.p3.16xlarge", + "ml.g4dn.xlarge", + "ml.g4dn.2xlarge", + "ml.g4dn.4xlarge", + "ml.g4dn.8xlarge", + "ml.g4dn.12xlarge", + "ml.g4dn.16xlarge" + ] + }, + "SageMakerImageArn": { + "type": "string", + "description": "The ARN of the SageMaker image that the image version belongs to.", + "maxLength": 256, + "pattern": "^arn:aws(-[\\w]+)*:sagemaker:.+:[0-9]{12}:image/[a-z0-9]([-.]?[a-z0-9])*$" + }, + "SageMakerImageVersionArn": { + "type": "string", + "description": "The ARN of the image version created on the instance.", + "maxLength": 256, + "pattern": "^arn:aws(-[\\w]+)*:sagemaker:.+:[0-9]{12}:image-version/[a-z0-9]([-.]?[a-z0-9])*/[0-9]+$" + } + } + }, + "KernelGatewayAppSettings": { + "type": "object", + "description": "The kernel gateway app settings.", + "additionalProperties": false, + "properties": { + "CustomImages": { + "type": "array", + "description": "A list of custom SageMaker images that are configured to run as a KernelGateway app.", + "uniqueItems": false, + "minItems": 0, + "maxItems": 30, + "items": { + "$ref": "#/definitions/CustomImage" + } + }, + "DefaultResourceSpec": { + "$ref": "#/definitions/ResourceSpec", + "description": "The default instance type and the Amazon Resource Name (ARN) of the default SageMaker image used by the KernelGateway app." + } + } + }, + "CustomImage": { + "type": "object", + "description": "A custom SageMaker image.", + "additionalProperties": false, + "properties": { + "AppImageConfigName": { + "type": "string", + "description": "The Name of the AppImageConfig.", + "maxLength": 63, + "pattern": "^[a-zA-Z0-9](-*[a-zA-Z0-9]){0,62}" + }, + "ImageName": { + "type": "string", + "description": "The name of the CustomImage. Must be unique to your account.", + "maxLength": 63, + "pattern": "^[a-zA-Z0-9]([-.]?[a-zA-Z0-9]){0,62}$" + }, + "ImageVersionNumber": { + "type": "integer", + "description": "The version number of the CustomImage.", + "minimum": 0 + } + }, + "required": [ + "AppImageConfigName", + "ImageName" + ] + }, + "SharingSettings": { + "type": "object", + "description": "Specifies options when sharing an Amazon SageMaker Studio notebook. These settings are specified as part of DefaultUserSettings when the CreateDomain API is called, and as part of UserSettings when the CreateUserProfile API is called.", + "additionalProperties": false, + "properties": { + "NotebookOutputOption": { + "type": "string", + "description": "Whether to include the notebook cell output when sharing the notebook. The default is Disabled.", + "enum": [ + "Allowed", + "Disabled" + ] + }, + "S3KmsKeyId": { + "type": "string", + "description": "When NotebookOutputOption is Allowed, the AWS Key Management Service (KMS) encryption key ID used to encrypt the notebook cell output in the Amazon S3 bucket.", + "maxLength": 2048, + "pattern": ".*" + }, + "S3OutputPath": { + "type": "string", + "description": "When NotebookOutputOption is Allowed, the Amazon S3 bucket used to store the shared notebook snapshots.", + "maxLength": 1024, + "pattern": "^(https|s3)://([^/]+)/?(.*)$" + } + } + }, + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "Key": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "Key", + "Value" + ] + } + }, + "required": [ + "DomainId", + "UserProfileName" + ], + "createOnlyProperties": [ + "/properties/DomainId", + "/properties/UserProfileName", + "/properties/SingleSignOnUserIdentifier", + "/properties/SingleSignOnUserValue", + "/properties/Tags" + ], + "writeOnlyProperties": [ + "/properties/Tags" + ], + "primaryIdentifier": [ + "/properties/UserProfileName", + "/properties/DomainId" + ], + "readOnlyProperties": [ + "/properties/UserProfileArn" + ], + "handlers": { + "create": { + "permissions": [ + "sagemaker:CreateUserProfile", + "sagemaker:DescribeUserProfile", + "sagemaker:DescribeImage", + "sagemaker:DescribeImageVersion", + "iam:PassRole" + ] + }, + "read": { + "permissions": [ + "sagemaker:DescribeUserProfile" + ] + }, + "update": { + "permissions": [ + "sagemaker:UpdateUserProfile", + "sagemaker:DescribeUserProfile", + "sagemaker:DescribeImage", + "sagemaker:DescribeImageVersion", + "iam:PassRole" + ] + }, + "delete": { + "permissions": [ + "sagemaker:DeleteUserProfile", + "sagemaker:DescribeUserProfile" + ] + }, + "list": { + "permissions": [ + "sagemaker:ListUserProfiles" + ] + } + } +} diff --git a/aws-sagemaker-userprofile/docs/README.md b/aws-sagemaker-userprofile/docs/README.md new file mode 100644 index 0000000..d499339 --- /dev/null +++ b/aws-sagemaker-userprofile/docs/README.md @@ -0,0 +1,126 @@ +# AWS::SageMaker::UserProfile + +Resource Type definition for AWS::SageMaker::UserProfile + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Type" : "AWS::SageMaker::UserProfile",
+    "Properties" : {
+        "DomainId" : String,
+        "SingleSignOnUserIdentifier" : String,
+        "SingleSignOnUserValue" : String,
+        "UserProfileName" : String,
+        "UserSettings" : UserSettings,
+        "Tags" : [ Tag, ... ]
+    }
+}
+
+ +### YAML + +
+Type: AWS::SageMaker::UserProfile
+Properties:
+    DomainId: String
+    SingleSignOnUserIdentifier: String
+    SingleSignOnUserValue: String
+    UserProfileName: String
+    UserSettings: UserSettings
+    Tags: 
+      - Tag
+
+ +## Properties + +#### DomainId + +The ID of the associated Domain. + +_Required_: Yes + +_Type_: String + +_Minimum_: 1 + +_Maximum_: 63 + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### SingleSignOnUserIdentifier + +A specifier for the type of value specified in SingleSignOnUserValue. Currently, the only supported value is "UserName". If the Domain's AuthMode is SSO, this field is required. If the Domain's AuthMode is not SSO, this field cannot be specified. + +_Required_: No + +_Type_: String + +_Pattern_: UserName + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### SingleSignOnUserValue + +The username of the associated AWS Single Sign-On User for this UserProfile. If the Domain's AuthMode is SSO, this field is required, and must match a valid username of a user in your directory. If the Domain's AuthMode is not SSO, this field cannot be specified. + +_Required_: No + +_Type_: String + +_Minimum_: 1 + +_Maximum_: 256 + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### UserProfileName + +A name for the UserProfile. + +_Required_: Yes + +_Type_: String + +_Minimum_: 1 + +_Maximum_: 63 + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### UserSettings + +A collection of settings that apply to users of Amazon SageMaker Studio. These settings are specified when the CreateUserProfile API is called, and as DefaultUserSettings when the CreateDomain API is called. + +_Required_: No + +_Type_: UserSettings + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Tags + +A list of tags to apply to the user profile. + +_Required_: No + +_Type_: List of Tag + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +## Return Values + +### Fn::GetAtt + +The `Fn::GetAtt` intrinsic function returns a value for a specified attribute of this type. The following are the available attributes and sample return values. + +For more information about using the `Fn::GetAtt` intrinsic function, see [Fn::GetAtt](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getatt.html). + +#### UserProfileArn + +The user profile Amazon Resource Name (ARN). + diff --git a/aws-sagemaker-userprofile/docs/customimage.md b/aws-sagemaker-userprofile/docs/customimage.md new file mode 100644 index 0000000..9d0254d --- /dev/null +++ b/aws-sagemaker-userprofile/docs/customimage.md @@ -0,0 +1,66 @@ +# AWS::SageMaker::UserProfile CustomImage + +A custom SageMaker image. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "AppImageConfigName" : String,
+    "ImageName" : String,
+    "ImageVersionNumber" : Integer
+}
+
+ +### YAML + +
+AppImageConfigName: String
+ImageName: String
+ImageVersionNumber: Integer
+
+ +## Properties + +#### AppImageConfigName + +The Name of the AppImageConfig. + +_Required_: Yes + +_Type_: String + +_Maximum_: 63 + +_Pattern_: ^[a-zA-Z0-9](-*[a-zA-Z0-9]){0,62} + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### ImageName + +The name of the CustomImage. Must be unique to your account. + +_Required_: Yes + +_Type_: String + +_Maximum_: 63 + +_Pattern_: ^[a-zA-Z0-9]([-.]?[a-zA-Z0-9]){0,62}$ + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### ImageVersionNumber + +The version number of the CustomImage. + +_Required_: No + +_Type_: Integer + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-sagemaker-userprofile/docs/jupyterserverappsettings.md b/aws-sagemaker-userprofile/docs/jupyterserverappsettings.md new file mode 100644 index 0000000..7921704 --- /dev/null +++ b/aws-sagemaker-userprofile/docs/jupyterserverappsettings.md @@ -0,0 +1,32 @@ +# AWS::SageMaker::UserProfile JupyterServerAppSettings + +The JupyterServer app settings. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "DefaultResourceSpec" : ResourceSpec
+}
+
+ +### YAML + +
+DefaultResourceSpec: ResourceSpec
+
+ +## Properties + +#### DefaultResourceSpec + +_Required_: No + +_Type_: ResourceSpec + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-sagemaker-userprofile/docs/kernelgatewayappsettings.md b/aws-sagemaker-userprofile/docs/kernelgatewayappsettings.md new file mode 100644 index 0000000..c27997b --- /dev/null +++ b/aws-sagemaker-userprofile/docs/kernelgatewayappsettings.md @@ -0,0 +1,45 @@ +# AWS::SageMaker::UserProfile KernelGatewayAppSettings + +The kernel gateway app settings. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "CustomImages" : [ CustomImage, ... ],
+    "DefaultResourceSpec" : ResourceSpec
+}
+
+ +### YAML + +
+CustomImages: 
+      - CustomImage
+DefaultResourceSpec: ResourceSpec
+
+ +## Properties + +#### CustomImages + +A list of custom SageMaker images that are configured to run as a KernelGateway app. + +_Required_: No + +_Type_: List of CustomImage + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### DefaultResourceSpec + +_Required_: No + +_Type_: ResourceSpec + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-sagemaker-userprofile/docs/resourcespec.md b/aws-sagemaker-userprofile/docs/resourcespec.md new file mode 100644 index 0000000..d0ed1ed --- /dev/null +++ b/aws-sagemaker-userprofile/docs/resourcespec.md @@ -0,0 +1,66 @@ +# AWS::SageMaker::UserProfile ResourceSpec + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "InstanceType" : String,
+    "SageMakerImageArn" : String,
+    "SageMakerImageVersionArn" : String
+}
+
+ +### YAML + +
+InstanceType: String
+SageMakerImageArn: String
+SageMakerImageVersionArn: String
+
+ +## Properties + +#### InstanceType + +The instance type that the image version runs on. + +_Required_: No + +_Type_: String + +_Allowed Values_: system | ml.t3.micro | ml.t3.small | ml.t3.medium | ml.t3.large | ml.t3.xlarge | ml.t3.2xlarge | ml.m5.large | ml.m5.xlarge | ml.m5.2xlarge | ml.m5.4xlarge | ml.m5.8xlarge | ml.m5.12xlarge | ml.m5.16xlarge | ml.m5.24xlarge | ml.c5.large | ml.c5.xlarge | ml.c5.2xlarge | ml.c5.4xlarge | ml.c5.9xlarge | ml.c5.12xlarge | ml.c5.18xlarge | ml.c5.24xlarge | ml.p3.2xlarge | ml.p3.8xlarge | ml.p3.16xlarge | ml.g4dn.xlarge | ml.g4dn.2xlarge | ml.g4dn.4xlarge | ml.g4dn.8xlarge | ml.g4dn.12xlarge | ml.g4dn.16xlarge + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### SageMakerImageArn + +The ARN of the SageMaker image that the image version belongs to. + +_Required_: No + +_Type_: String + +_Maximum_: 256 + +_Pattern_: ^arn:aws(-[\w]+)*:sagemaker:.+:[0-9]{12}:image/[a-z0-9]([-.]?[a-z0-9])*$ + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### SageMakerImageVersionArn + +The ARN of the image version created on the instance. + +_Required_: No + +_Type_: String + +_Maximum_: 256 + +_Pattern_: ^arn:aws(-[\w]+)*:sagemaker:.+:[0-9]{12}:image-version/[a-z0-9]([-.]?[a-z0-9])*/[0-9]+$ + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-sagemaker-userprofile/docs/sharingsettings.md b/aws-sagemaker-userprofile/docs/sharingsettings.md new file mode 100644 index 0000000..0afc8e8 --- /dev/null +++ b/aws-sagemaker-userprofile/docs/sharingsettings.md @@ -0,0 +1,68 @@ +# AWS::SageMaker::UserProfile SharingSettings + +Specifies options when sharing an Amazon SageMaker Studio notebook. These settings are specified as part of DefaultUserSettings when the CreateDomain API is called, and as part of UserSettings when the CreateUserProfile API is called. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "NotebookOutputOption" : String,
+    "S3KmsKeyId" : String,
+    "S3OutputPath" : String
+}
+
+ +### YAML + +
+NotebookOutputOption: String
+S3KmsKeyId: String
+S3OutputPath: String
+
+ +## Properties + +#### NotebookOutputOption + +Whether to include the notebook cell output when sharing the notebook. The default is Disabled. + +_Required_: No + +_Type_: String + +_Allowed Values_: Allowed | Disabled + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### S3KmsKeyId + +When NotebookOutputOption is Allowed, the AWS Key Management Service (KMS) encryption key ID used to encrypt the notebook cell output in the Amazon S3 bucket. + +_Required_: No + +_Type_: String + +_Maximum_: 2048 + +_Pattern_: .* + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### S3OutputPath + +When NotebookOutputOption is Allowed, the Amazon S3 bucket used to store the shared notebook snapshots. + +_Required_: No + +_Type_: String + +_Maximum_: 1024 + +_Pattern_: ^(https|s3)://([^/]+)/?(.*)$ + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-sagemaker-userprofile/docs/tag.md b/aws-sagemaker-userprofile/docs/tag.md new file mode 100644 index 0000000..8d86d91 --- /dev/null +++ b/aws-sagemaker-userprofile/docs/tag.md @@ -0,0 +1,48 @@ +# AWS::SageMaker::UserProfile Tag + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Value" : String,
+    "Key" : String
+}
+
+ +### YAML + +
+Value: String
+Key: String
+
+ +## Properties + +#### Value + +_Required_: Yes + +_Type_: String + +_Minimum_: 1 + +_Maximum_: 128 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Key + +_Required_: Yes + +_Type_: String + +_Minimum_: 1 + +_Maximum_: 128 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-sagemaker-userprofile/docs/usersettings.md b/aws-sagemaker-userprofile/docs/usersettings.md new file mode 100644 index 0000000..dee918c --- /dev/null +++ b/aws-sagemaker-userprofile/docs/usersettings.md @@ -0,0 +1,89 @@ +# AWS::SageMaker::UserProfile UserSettings + +A collection of settings that apply to users of Amazon SageMaker Studio. These settings are specified when the CreateUserProfile API is called, and as DefaultUserSettings when the CreateDomain API is called. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "ExecutionRole" : String,
+    "JupyterServerAppSettings" : JupyterServerAppSettings,
+    "KernelGatewayAppSettings" : KernelGatewayAppSettings,
+    "SecurityGroups" : [ String, ... ],
+    "SharingSettings" : SharingSettings
+}
+
+ +### YAML + +
+ExecutionRole: String
+JupyterServerAppSettings: JupyterServerAppSettings
+KernelGatewayAppSettings: KernelGatewayAppSettings
+SecurityGroups: 
+      - String
+SharingSettings: SharingSettings
+
+ +## Properties + +#### ExecutionRole + +The user profile Amazon Resource Name (ARN). + +_Required_: No + +_Type_: String + +_Minimum_: 20 + +_Maximum_: 2048 + +_Pattern_: ^arn:aws[a-z\-]*:iam::\d{12}:role/?[a-zA-Z_0-9+=,.@\-_/]+$ + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### JupyterServerAppSettings + +The JupyterServer app settings. + +_Required_: No + +_Type_: JupyterServerAppSettings + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### KernelGatewayAppSettings + +The kernel gateway app settings. + +_Required_: No + +_Type_: KernelGatewayAppSettings + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### SecurityGroups + +The security groups for the Amazon Virtual Private Cloud (VPC) that Studio uses for communication. + +_Required_: No + +_Type_: List of String + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### SharingSettings + +Specifies options when sharing an Amazon SageMaker Studio notebook. These settings are specified as part of DefaultUserSettings when the CreateDomain API is called, and as part of UserSettings when the CreateUserProfile API is called. + +_Required_: No + +_Type_: SharingSettings + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-sagemaker-userprofile/lombok.config b/aws-sagemaker-userprofile/lombok.config new file mode 100644 index 0000000..7a21e88 --- /dev/null +++ b/aws-sagemaker-userprofile/lombok.config @@ -0,0 +1 @@ +lombok.addLombokGeneratedAnnotation = true diff --git a/aws-sagemaker-userprofile/pom.xml b/aws-sagemaker-userprofile/pom.xml new file mode 100644 index 0000000..882dad3 --- /dev/null +++ b/aws-sagemaker-userprofile/pom.xml @@ -0,0 +1,210 @@ + + + 4.0.0 + + software.amazon.sagemaker.userprofile + aws-sagemaker-userprofile-handler + aws-sagemaker-userprofile-handler + 1.0-SNAPSHOT + jar + + + 1.8 + 1.8 + UTF-8 + UTF-8 + + + + + + software.amazon.awssdk + sagemaker + 2.15.41 + + + + software.amazon.cloudformation + aws-cloudformation-rpdk-java-plugin + [2.0.0,3.0.0) + + + + org.projectlombok + lombok + 1.18.4 + provided + + + + + org.assertj + assertj-core + 3.12.2 + test + + + + org.junit.jupiter + junit-jupiter + 5.5.0-M1 + test + + + + org.mockito + mockito-core + 3.6.0 + test + + + + org.mockito + mockito-junit-jupiter + 3.6.0 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + -Xlint:all,-options,-processing + -Werror + + + + + org.apache.maven.plugins + maven-shade-plugin + 2.3 + + false + + + + package + + shade + + + + + + org.codehaus.mojo + exec-maven-plugin + 1.6.0 + + + generate + generate-sources + + exec + + + cfn + generate + ${project.basedir} + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.0.0 + + + add-source + generate-sources + + add-source + + + + ${project.basedir}/target/generated-sources/rpdk + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 2.4 + + + maven-surefire-plugin + 3.0.0-M3 + + + org.jacoco + jacoco-maven-plugin + 0.8.4 + + + **/BaseConfiguration* + **/BaseHandler* + **/HandlerWrapper* + **/ResourceModel* + + + + + + prepare-agent + + + + report + test + + report + + + + jacoco-check + + check + + + + + PACKAGE + + + BRANCH + COVEREDRATIO + 0.8 + + + INSTRUCTION + COVEREDRATIO + 0.8 + + + + + + + + + + + + ${project.basedir} + + aws-sagemaker-userprofile.json + + + + + diff --git a/aws-sagemaker-userprofile/resource-role.yaml b/aws-sagemaker-userprofile/resource-role.yaml new file mode 100644 index 0000000..e1c0130 --- /dev/null +++ b/aws-sagemaker-userprofile/resource-role.yaml @@ -0,0 +1,38 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: > + This CloudFormation template creates a role assumed by CloudFormation + during CRUDL operations to mutate resources on behalf of the customer. + +Resources: + ExecutionRole: + Type: AWS::IAM::Role + Properties: + MaxSessionDuration: 8400 + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: resources.cloudformation.amazonaws.com + Action: sts:AssumeRole + Path: "/" + Policies: + - PolicyName: ResourceTypePolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - "iam:PassRole" + - "sagemaker:CreateUserProfile" + - "sagemaker:DeleteUserProfile" + - "sagemaker:DescribeImage" + - "sagemaker:DescribeImageVersion" + - "sagemaker:DescribeUserProfile" + - "sagemaker:ListUserProfiles" + - "sagemaker:UpdateUserProfile" + Resource: "*" +Outputs: + ExecutionRoleArn: + Value: + Fn::GetAtt: ExecutionRole.Arn diff --git a/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/BaseHandlerStd.java b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/BaseHandlerStd.java new file mode 100644 index 0000000..03c34e0 --- /dev/null +++ b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/BaseHandlerStd.java @@ -0,0 +1,36 @@ +package software.amazon.sagemaker.userprofile; + +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +/** + * Common handler function definition for Create/Read/Update/Delete/List handlers. + */ +public abstract class BaseHandlerStd extends BaseHandler { + + @Override + public final ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final Logger logger) { + return handleRequest( + proxy, + request, + callbackContext != null ? callbackContext : new CallbackContext(), + proxy.newProxy(ClientBuilder::getClient), + logger + ); + } + + protected abstract ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger); +} \ No newline at end of file diff --git a/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/CallbackContext.java b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/CallbackContext.java new file mode 100644 index 0000000..69ec61e --- /dev/null +++ b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/CallbackContext.java @@ -0,0 +1,10 @@ +package software.amazon.sagemaker.userprofile; + +import software.amazon.cloudformation.proxy.StdCallbackContext; + +@lombok.Getter +@lombok.Setter +@lombok.ToString +@lombok.EqualsAndHashCode(callSuper = true) +public class CallbackContext extends StdCallbackContext { +} diff --git a/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/ClientBuilder.java b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/ClientBuilder.java new file mode 100644 index 0000000..f1dadff --- /dev/null +++ b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/ClientBuilder.java @@ -0,0 +1,12 @@ +package software.amazon.sagemaker.userprofile; + +import software.amazon.awssdk.services.sagemaker.SageMakerClient; + +/** + * Provides APIs to build the service client. + */ +public class ClientBuilder { + public static SageMakerClient getClient() { + return SageMakerClient.builder().build(); + } +} diff --git a/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/Configuration.java b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/Configuration.java new file mode 100644 index 0000000..520b091 --- /dev/null +++ b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/Configuration.java @@ -0,0 +1,8 @@ +package software.amazon.sagemaker.userprofile; + +class Configuration extends BaseConfiguration { + + public Configuration() { + super("aws-sagemaker-userprofile.json"); + } +} diff --git a/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/CreateHandler.java b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/CreateHandler.java new file mode 100644 index 0000000..4bc30d9 --- /dev/null +++ b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/CreateHandler.java @@ -0,0 +1,137 @@ +package software.amazon.sagemaker.userprofile; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.CreateUserProfileRequest; +import software.amazon.awssdk.services.sagemaker.model.CreateUserProfileResponse; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.awssdk.services.sagemaker.model.UserProfileStatus; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnInvalidRequestException; +import software.amazon.cloudformation.exceptions.CfnNotStabilizedException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class CreateHandler extends BaseHandlerStd { + + private static final String OPERATION = "AWS-SageMaker-UserProfile::Create"; + private static final String READ_ONLY_PROPERTY_ERROR_MESSAGE = "The following property '%s' is not allowed to configured."; + + private Logger logger; + + protected ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + + final ResourceModel model = request.getDesiredResourceState(); + + // read only properties are not allowed to be set by the user during creation. + // https://github.com/aws-cloudformation/aws-cloudformation-resource-schema/issues/102 + if (callbackContext.callGraphs().isEmpty()) { + if (model.getUserProfileArn() != null) { + throw new CfnInvalidRequestException(String.format(READ_ONLY_PROPERTY_ERROR_MESSAGE, "UserProfileArn")); + } + } + + return ProgressEvent.progress(model, callbackContext) + .then(progress -> + proxy.initiate(OPERATION, proxyClient, model, callbackContext) + .translateToServiceRequest(TranslatorForRequest::translateToCreateRequest) + .makeServiceCall(this::createResource) + .stabilize(this::stabilizedOnCreate) + .done(createResponse -> constructResourceModelFromResponse(model, createResponse)) + ); + } + + /** + * Client invocation of the create request through the proxyClient + * + * @param createRequest aws service create resource request + * @param proxyClient aws service client to make the call + * @return create resource response + */ + private CreateUserProfileResponse createResource( + final CreateUserProfileRequest createRequest, + final ProxyClient proxyClient) { + CreateUserProfileResponse response = null; + try { + response = proxyClient.injectCredentialsAndInvokeV2( + createRequest, proxyClient.client()::createUserProfile); + } catch (final AwsServiceException e) { + Translator.throwCfnException(Action.CREATE.toString(), ResourceModel.TYPE_NAME, createRequest.userProfileName(), e); + } + return response; + } + + /** + * Ensure resource has moved from pending to terminal state. + * + * @param awsRequest the aws service request to create a resource + * @param awsResponse the aws service response to create a resource + * @param proxyClient the aws service client to make the call + * @param model Resource Model + * @param callbackContext call back context + * @return boolean flag indicate if the creation is stabilized + */ + private boolean stabilizedOnCreate( + final CreateUserProfileRequest awsRequest, + final CreateUserProfileResponse awsResponse, + final ProxyClient proxyClient, + final ResourceModel model, + final CallbackContext callbackContext) { + + if (model.getUserProfileName() == null) { + model.setUserProfileName(awsRequest.userProfileName()); + } + + if (model.getDomainId() == null) { + model.setDomainId(awsRequest.domainId()); + } + + final UserProfileStatus UserProfileStatus; + try { + UserProfileStatus = proxyClient.injectCredentialsAndInvokeV2( + TranslatorForRequest.translateToReadRequest(model), + proxyClient.client()::describeUserProfile).status(); + } catch (ResourceNotFoundException rnfe) { + logger.log(String.format("Resource not found for %s, stabilizing.", model.getPrimaryIdentifier())); + return false; + } + + switch (UserProfileStatus) { + case IN_SERVICE: + logger.log(String.format("%s [%s] has been stabilized with status %s.", ResourceModel.TYPE_NAME, + model.getPrimaryIdentifier(), UserProfileStatus)); + return true; + case PENDING: + logger.log(String.format("%s [%s] is stabilizing.", ResourceModel.TYPE_NAME, + model.getPrimaryIdentifier())); + return false; + default: + logger.log(String.format("%s [%s] failed to stabilize with status: %s.", ResourceModel.TYPE_NAME, + model.getPrimaryIdentifier(), UserProfileStatus)); + throw new CfnNotStabilizedException(ResourceModel.TYPE_NAME, model.getUserProfileName()); + } + } + + /** + * Build the Progress Event object from the create response. + * + * @param model resource model + * @param awsResponse aws service create resource response + * @return progressEvent indicating success + */ + private ProgressEvent constructResourceModelFromResponse( + final ResourceModel model, final CreateUserProfileResponse awsResponse) { + model.setUserProfileArn(awsResponse.userProfileArn()); + return ProgressEvent.defaultSuccessHandler(model); + } +} diff --git a/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/DeleteHandler.java b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/DeleteHandler.java new file mode 100644 index 0000000..b8fb501 --- /dev/null +++ b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/DeleteHandler.java @@ -0,0 +1,107 @@ +package software.amazon.sagemaker.userprofile; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.DeleteUserProfileRequest; +import software.amazon.awssdk.services.sagemaker.model.DeleteUserProfileResponse; +import software.amazon.awssdk.services.sagemaker.model.ResourceInUseException; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.awssdk.services.sagemaker.model.UserProfileStatus; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnNotStabilizedException; +import software.amazon.cloudformation.exceptions.CfnResourceConflictException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class DeleteHandler extends BaseHandlerStd { + + private static final String OPERATION = "AWS-SageMaker-UserProfile::Delete"; + + private Logger logger; + + protected ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + + final ResourceModel model = request.getDesiredResourceState(); + + return ProgressEvent.progress(model, callbackContext) + .then(progress -> + proxy.initiate(OPERATION, proxyClient, model, callbackContext) + .translateToServiceRequest(TranslatorForRequest::translateToDeleteRequest) + .makeServiceCall(this::deleteResource) + .stabilize(this::stabilizedOnDelete) + .done(awsResponse -> ProgressEvent.builder() + .status(OperationStatus.SUCCESS) + .build())); + } + + /** + * Implement client invocation of the delete request through the proxyClient. + * + * @param awsRequest the aws service delete resource request + * @param proxyClient the aws service client to make the call + * @return delete resource response + */ + private DeleteUserProfileResponse deleteResource( + final DeleteUserProfileRequest awsRequest, + final ProxyClient proxyClient) { + + DeleteUserProfileResponse response = null; + try { + response = proxyClient.injectCredentialsAndInvokeV2(awsRequest, proxyClient.client()::deleteUserProfile); + } catch (final ResourceInUseException riue) { + // ResourceInUseException is handled differently for deletes, as deletes can fail due to associated Apps + final String primaryIdentifier = String.format("%s|%s", awsRequest.domainId(), awsRequest.userProfileName()); + throw new CfnResourceConflictException(ResourceModel.TYPE_NAME, primaryIdentifier, riue.getMessage(), riue); + } catch (final AwsServiceException e) { + Translator.throwCfnException(Action.DELETE.toString(), ResourceModel.TYPE_NAME, + awsRequest.userProfileName(), e); + } + + return response; + } + + /** + * Ensure resource has moved from pending to terminal state. + * + * @param awsRequest the aws service delete resource request + * @param awsResponse the aws service delete resource response + * @param proxyClient the aws service client to make the call + * @param model resource model + * @param callbackContext callback context + * @return boolean to indicate if the creation is stabilized + */ + private boolean stabilizedOnDelete( + final DeleteUserProfileRequest awsRequest, + final DeleteUserProfileResponse awsResponse, + final ProxyClient proxyClient, + final ResourceModel model, + final CallbackContext callbackContext) { + try { + final UserProfileStatus UserProfileStatus = + proxyClient.injectCredentialsAndInvokeV2(TranslatorForRequest.translateToReadRequest(model), + proxyClient.client()::describeUserProfile).status(); + + switch (UserProfileStatus) { + case DELETING: + logger.log(String.format("%s with name [%s] is stabilizing while delete.", + ResourceModel.TYPE_NAME, model.getPrimaryIdentifier())); + return false; + default: + throw new CfnNotStabilizedException(ResourceModel.TYPE_NAME, model.getUserProfileName()); + } + } catch (ResourceNotFoundException e) { + return true; + } + } +} diff --git a/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/ListHandler.java b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/ListHandler.java new file mode 100644 index 0000000..47f0a72 --- /dev/null +++ b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/ListHandler.java @@ -0,0 +1,74 @@ +package software.amazon.sagemaker.userprofile; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.ListUserProfilesRequest; +import software.amazon.awssdk.services.sagemaker.model.ListUserProfilesResponse; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class ListHandler extends BaseHandlerStd { + + private static final String OPERATION = "AWS-SageMaker-UserProfile::List"; + + private Logger logger; + + @Override + public ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + + final ResourceModel model = request.getDesiredResourceState(); + + return proxy.initiate(OPERATION, proxyClient, model, callbackContext) + .translateToServiceRequest(resourceModel -> TranslatorForRequest.translateToListRequest(request.getNextToken())) + .makeServiceCall(this::listResources) + .done(this::constructResourceModelFromResponse); + } + + /** + * Client invocation of the list request through the proxyClient. + * + * @param awsRequest the aws service request to list a resource + * @param proxyClient the aws service client to make the call + * @return aws service list resource response + */ + private ListUserProfilesResponse listResources( + final ListUserProfilesRequest awsRequest, + final ProxyClient proxyClient) { + + ListUserProfilesResponse response = null; + try { + response = proxyClient.injectCredentialsAndInvokeV2(awsRequest, proxyClient.client()::listUserProfiles); + } catch (final AwsServiceException e) { + Translator.throwCfnException(Action.LIST.toString(), ResourceModel.TYPE_NAME, null, e); + } + + return response; + } + + /** + * Build the Progress Event object from the list resource response. + * + * @param listResponse the aws service list resource response + * @return progressEvent indicating success, in progress, delay, callback or failed state + */ + private ProgressEvent constructResourceModelFromResponse( + final ListUserProfilesResponse listResponse) { + return ProgressEvent.builder() + .nextToken(listResponse.nextToken()) + .resourceModels(TranslatorForResponse.translateFromListResponse(listResponse)) + .status(OperationStatus.SUCCESS) + .build(); + } +} diff --git a/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/ReadHandler.java b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/ReadHandler.java new file mode 100644 index 0000000..9e590aa --- /dev/null +++ b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/ReadHandler.java @@ -0,0 +1,69 @@ +package software.amazon.sagemaker.userprofile; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.DescribeUserProfileRequest; +import software.amazon.awssdk.services.sagemaker.model.DescribeUserProfileResponse; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class ReadHandler extends BaseHandlerStd { + + private static final String OPERATION = "AWS-SageMaker-UserProfile::Read"; + + private Logger logger; + + + @Override + public ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + final ResourceModel model = request.getDesiredResourceState(); + + return proxy.initiate(OPERATION, proxyClient, model, callbackContext) + .translateToServiceRequest(TranslatorForRequest::translateToReadRequest) + .makeServiceCall(this::readResource) + .done(this::constructResourceModelFromResponse); + } + + /** + * Client invocation of the read request through the proxyClient. + * + * @param awsRequest the aws service describe resource request + * @param proxyClient the aws service client to make the call + * @return describe resource response + */ + private DescribeUserProfileResponse readResource( + final DescribeUserProfileRequest awsRequest, + final ProxyClient proxyClient) { + DescribeUserProfileResponse response = null; + try { + response = proxyClient.injectCredentialsAndInvokeV2(awsRequest, proxyClient.client()::describeUserProfile); + } catch (final AwsServiceException e) { + Translator.throwCfnException(Action.READ.toString(), ResourceModel.TYPE_NAME, + awsRequest.userProfileName(), e); + } + + return response; + } + + /** + * Client invocation of the read request through the proxyClient. + * + * @param awsResponse the aws service describe resource response + * @return progressEvent indicating success, in progress, delay, callback or failed state + */ + private ProgressEvent constructResourceModelFromResponse( + final DescribeUserProfileResponse awsResponse) { + return ProgressEvent.defaultSuccessHandler(TranslatorForResponse.translateFromReadResponse(awsResponse)); + } +} diff --git a/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/Translator.java b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/Translator.java new file mode 100644 index 0000000..109324d --- /dev/null +++ b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/Translator.java @@ -0,0 +1,81 @@ +package software.amazon.sagemaker.userprofile; + +import org.apache.commons.lang3.StringUtils; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.model.ResourceInUseException; +import software.amazon.awssdk.services.sagemaker.model.ResourceLimitExceededException; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.cloudformation.exceptions.CfnAccessDeniedException; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnInvalidRequestException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.exceptions.CfnServiceInternalErrorException; +import software.amazon.cloudformation.exceptions.CfnServiceLimitExceededException; +import software.amazon.cloudformation.exceptions.CfnThrottlingException; +import software.amazon.cloudformation.exceptions.ResourceAlreadyExistsException; + +import java.util.Collection; +import java.util.Optional; +import java.util.stream.Stream; + +/** + * Contains common methods required by other translators. + */ +public class Translator { + + /** + * Throws a Cfn exception for the corresponding error code of the given exception. + * + * @param operation operation + * @param resourceType resource type + * @param resourceName resource name + * @param e exception + */ + static void throwCfnException( + final String operation, + final String resourceType, + final String resourceName, + final AwsServiceException e + ) { + + if (e instanceof ResourceInUseException) { + throw new ResourceAlreadyExistsException(resourceType, resourceName, e); + } + + if (e instanceof ResourceNotFoundException) { + throw new CfnNotFoundException(resourceType, resourceName, e); + } + + if (e instanceof ResourceLimitExceededException) { + throw new CfnServiceLimitExceededException(resourceType, e.getMessage(), e); + } + + if(e.awsErrorDetails() != null && StringUtils.isNotBlank(e.awsErrorDetails().errorCode())) { + String errorMessage = e.awsErrorDetails().errorMessage(); + switch (e.awsErrorDetails().errorCode()) { + case "UnauthorizedOperation": + throw new CfnAccessDeniedException(errorMessage, e); + case "InvalidParameter": + case "InvalidParameterValue": + case "ValidationError": + case "ValidationException": + throw new CfnInvalidRequestException(errorMessage, e); + case "InternalError": + case "ServiceUnavailable": + throw new CfnServiceInternalErrorException(errorMessage, e); + case "ThrottlingException": + throw new CfnThrottlingException(errorMessage, e); + default: + throw new CfnGeneralServiceException(errorMessage, e); + } + } + + throw new CfnGeneralServiceException(operation, e); + } + + static Stream streamOfOrEmpty(final Collection collection) { + return Optional.ofNullable(collection) + .map(Collection::stream) + .orElseGet(Stream::empty); + } +} \ No newline at end of file diff --git a/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/TranslatorForRequest.java b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/TranslatorForRequest.java new file mode 100644 index 0000000..12f064a --- /dev/null +++ b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/TranslatorForRequest.java @@ -0,0 +1,167 @@ +package software.amazon.sagemaker.userprofile; + +import software.amazon.awssdk.services.sagemaker.model.CreateUserProfileRequest; +import software.amazon.awssdk.services.sagemaker.model.DeleteUserProfileRequest; +import software.amazon.awssdk.services.sagemaker.model.DescribeUserProfileRequest; +import software.amazon.awssdk.services.sagemaker.model.ListUserProfilesRequest; +import software.amazon.awssdk.services.sagemaker.model.Tag; +import software.amazon.awssdk.services.sagemaker.model.UpdateUserProfileRequest; + +import java.util.List; +import java.util.stream.Collectors; + +final class TranslatorForRequest { + + private TranslatorForRequest() {} + + /** + * Translates ResourceModel input to an aws sdk create resource request. + * + * @param model resource model + * @return aws sdk create resource request + */ + static CreateUserProfileRequest translateToCreateRequest(final ResourceModel model) { + return CreateUserProfileRequest.builder() + .domainId(model.getDomainId()) + .singleSignOnUserIdentifier(model.getSingleSignOnUserIdentifier()) + .singleSignOnUserValue(model.getSingleSignOnUserValue()) + .userProfileName(model.getUserProfileName()) + .userSettings(translateUserSettings(model.getUserSettings())) + .tags(Translator.streamOfOrEmpty(model.getTags()) + .map(t -> Tag.builder() + .key(t.getKey()) + .value(t.getValue()) + .build()) + .collect(Collectors.toList())) + .build(); + } + + /** + * Translates ResourceModel input to an aws sdk read resource request. + * + * @param model resource model + * @return aws sdk read resource request + */ + static DescribeUserProfileRequest translateToReadRequest(final ResourceModel model) { + return DescribeUserProfileRequest.builder() + .userProfileName(model.getUserProfileName()) + .domainId(model.getDomainId()) + .build(); + } + + /** + * Translates ResourceModel input to an aws sdk delete resource request. + * + * @param model resource model + * @return aws sdk delete resource request + */ + static DeleteUserProfileRequest translateToDeleteRequest(final ResourceModel model) { + return DeleteUserProfileRequest.builder() + .userProfileName(model.getUserProfileName()) + .domainId(model.getDomainId()) + .build(); + } + + /** + * Translates ResourceModel input to an aws sdk update resource request. + * + * @param model resource model + * @return aws sdk delete resource request + */ + static UpdateUserProfileRequest translateToUpdateRequest(final ResourceModel model) { + return UpdateUserProfileRequest.builder() + .domainId(model.getDomainId()) + .userProfileName(model.getUserProfileName()) + .userSettings(translateUserSettings(model.getUserSettings())) + .build(); + } + + /** + * Translates ResourceModel input to an aws sdk list resource request. + * + * @param nextToken token passed to the aws service describe resource request + * @return list resource request + */ + static ListUserProfilesRequest translateToListRequest(final String nextToken) { + return ListUserProfilesRequest.builder().nextToken(nextToken).build(); + } + + private static software.amazon.awssdk.services.sagemaker.model.UserSettings translateUserSettings( + UserSettings origin) { + if (origin == null) { + return null; + } + + return software.amazon.awssdk.services.sagemaker.model.UserSettings.builder() + .executionRole(origin.getExecutionRole()) + .jupyterServerAppSettings(translateJupyterServerAppSettings(origin.getJupyterServerAppSettings())) + .kernelGatewayAppSettings(translateKernelGatewayAppSettings(origin.getKernelGatewayAppSettings())) + .securityGroups(origin.getSecurityGroups()) + .sharingSettings(translateSharingSettings(origin.getSharingSettings())) + .build(); + } + + private static software.amazon.awssdk.services.sagemaker.model.JupyterServerAppSettings translateJupyterServerAppSettings( + JupyterServerAppSettings origin) { + if (origin == null) { + return null; + } + + return software.amazon.awssdk.services.sagemaker.model.JupyterServerAppSettings.builder() + .defaultResourceSpec(translateResourceSpec(origin.getDefaultResourceSpec())) + .build(); + } + + private static software.amazon.awssdk.services.sagemaker.model.KernelGatewayAppSettings translateKernelGatewayAppSettings( + KernelGatewayAppSettings origin) { + if (origin == null) { + return null; + } + + return software.amazon.awssdk.services.sagemaker.model.KernelGatewayAppSettings.builder() + .customImages(translateCustomImages(origin.getCustomImages())) + .defaultResourceSpec(translateResourceSpec(origin.getDefaultResourceSpec())) + .build(); + } + + private static software.amazon.awssdk.services.sagemaker.model.ResourceSpec translateResourceSpec( + ResourceSpec origin) { + if (origin == null) { + return null; + } + + return software.amazon.awssdk.services.sagemaker.model.ResourceSpec.builder() + .instanceType(origin.getInstanceType()) + .sageMakerImageArn(origin.getSageMakerImageArn()) + .sageMakerImageVersionArn(origin.getSageMakerImageVersionArn()) + .build(); + } + + private static List translateCustomImages( + List origin) { + if (origin == null) { + return null; + } + + return Translator.streamOfOrEmpty(origin) + .map(image -> software.amazon.awssdk.services.sagemaker.model.CustomImage.builder() + .appImageConfigName(image.getAppImageConfigName()) + .imageName(image.getImageName()) + .imageVersionNumber(image.getImageVersionNumber()) + .build()) + .collect(Collectors.toList()); + } + + private static software.amazon.awssdk.services.sagemaker.model.SharingSettings translateSharingSettings( + SharingSettings origin) { + if (origin == null) { + return null; + } + + return software.amazon.awssdk.services.sagemaker.model.SharingSettings.builder() + .notebookOutputOption(origin.getNotebookOutputOption()) + .s3KmsKeyId(origin.getS3KmsKeyId()) + .s3OutputPath(origin.getS3OutputPath()) + .build(); + } +} \ No newline at end of file diff --git a/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/TranslatorForResponse.java b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/TranslatorForResponse.java new file mode 100644 index 0000000..719d80e --- /dev/null +++ b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/TranslatorForResponse.java @@ -0,0 +1,123 @@ +package software.amazon.sagemaker.userprofile; + +import software.amazon.awssdk.services.sagemaker.model.DescribeUserProfileResponse; +import software.amazon.awssdk.services.sagemaker.model.ListUserProfilesResponse; +import software.amazon.awssdk.services.sagemaker.model.ResourceSpec; + +import java.util.List; +import java.util.stream.Collectors; + +public class TranslatorForResponse { + + private TranslatorForResponse() {} + + /** + * Translates the AWS SDK read response into a native resource model. + * + * @param awsResponse the aws service describe resource response + * @return model resource model + */ + static ResourceModel translateFromReadResponse(final DescribeUserProfileResponse awsResponse) { + return ResourceModel.builder() + .userProfileArn(awsResponse.userProfileArn()) + .userProfileName(awsResponse.userProfileName()) + .domainId(awsResponse.domainId()) + .singleSignOnUserIdentifier(awsResponse.singleSignOnUserIdentifier()) + .singleSignOnUserValue(awsResponse.singleSignOnUserValue()) + .userSettings(translateUserSettings(awsResponse.userSettings())) + .build(); + } + + /** + * Translates the AWS SDK list response into a native resource model. + * + * @param awsResponse the aws service list resource response + * @return list of resource models + */ + static List translateFromListResponse(final ListUserProfilesResponse awsResponse) { + return Translator.streamOfOrEmpty(awsResponse.userProfiles()) + .map(UserProfile -> ResourceModel.builder() + .domainId(UserProfile.domainId()) + .userProfileName(UserProfile.userProfileName()) + .build()) + .collect(Collectors.toList()); + } + + private static UserSettings translateUserSettings( + software.amazon.awssdk.services.sagemaker.model.UserSettings origin) { + if (origin == null) { + return null; + } + + return UserSettings.builder() + .executionRole(origin.executionRole()) + .jupyterServerAppSettings(translateJupyterServerAppSettings(origin.jupyterServerAppSettings())) + .kernelGatewayAppSettings(translateKernelGatewayAppSettings(origin.kernelGatewayAppSettings())) + .securityGroups(origin.hasSecurityGroups() ? origin.securityGroups() : null) + .sharingSettings(translateSharingSettings(origin.sharingSettings())) + .build(); + } + + private static JupyterServerAppSettings translateJupyterServerAppSettings( + software.amazon.awssdk.services.sagemaker.model.JupyterServerAppSettings origin) { + if (origin == null) { + return null; + } + + return JupyterServerAppSettings.builder() + .defaultResourceSpec(translateResourceSpec(origin.defaultResourceSpec())) + .build(); + } + + private static KernelGatewayAppSettings translateKernelGatewayAppSettings( + software.amazon.awssdk.services.sagemaker.model.KernelGatewayAppSettings origin) { + if (origin == null) { + return null; + } + + return KernelGatewayAppSettings.builder() + .customImages(translateCustomImages(origin.customImages())) + .defaultResourceSpec(translateResourceSpec(origin.defaultResourceSpec())) + .build(); + } + + private static software.amazon.sagemaker.userprofile.ResourceSpec translateResourceSpec(ResourceSpec origin) { + if (origin == null) { + return null; + } + + return software.amazon.sagemaker.userprofile.ResourceSpec.builder() + .instanceType(origin.instanceTypeAsString()) + .sageMakerImageArn(origin.sageMakerImageArn()) + .sageMakerImageVersionArn(origin.sageMakerImageVersionArn()) + .build(); + } + + private static List translateCustomImages( + List origin) { + if (origin.isEmpty()) { + return null; + } + + return Translator.streamOfOrEmpty(origin) + .map(image -> software.amazon.sagemaker.userprofile.CustomImage.builder() + .appImageConfigName(image.appImageConfigName()) + .imageName(image.imageName()) + .imageVersionNumber(image.imageVersionNumber()) + .build()) + .collect(Collectors.toList()); + } + + private static SharingSettings translateSharingSettings( + software.amazon.awssdk.services.sagemaker.model.SharingSettings origin) { + if (origin == null) { + return null; + } + + return software.amazon.sagemaker.userprofile.SharingSettings.builder() + .notebookOutputOption(origin.notebookOutputOptionAsString()) + .s3KmsKeyId(origin.s3KmsKeyId()) + .s3OutputPath(origin.s3OutputPath()) + .build(); + } +} diff --git a/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/UpdateHandler.java b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/UpdateHandler.java new file mode 100644 index 0000000..de01498 --- /dev/null +++ b/aws-sagemaker-userprofile/src/main/java/software/amazon/sagemaker/userprofile/UpdateHandler.java @@ -0,0 +1,107 @@ +package software.amazon.sagemaker.userprofile; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.UpdateUserProfileRequest; +import software.amazon.awssdk.services.sagemaker.model.UpdateUserProfileResponse; +import software.amazon.awssdk.services.sagemaker.model.UserProfileStatus; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class UpdateHandler extends BaseHandlerStd { + + private static final String OPERATION = "AWS-SageMaker-UserProfile::Update"; + private Logger logger; + + @Override + protected ProgressEvent handleRequest( + AmazonWebServicesClientProxy proxy, + ResourceHandlerRequest request, + CallbackContext callbackContext, + ProxyClient proxyClient, + Logger logger + ) { + this.logger = logger; + final ResourceModel model = request.getDesiredResourceState(); + + return ProgressEvent.progress(model, callbackContext) + .then(progress -> + proxy.initiate(OPERATION, proxyClient, model, callbackContext) + .translateToServiceRequest(TranslatorForRequest::translateToUpdateRequest) + .makeServiceCall(this::updateResource) + .stabilize(this::stabilizedOnUpdate) + .done(updateResponse -> constructResourceModelFromResponse(model, updateResponse)) + ); + } + + /** + * Client invocation of the update request through the proxyClient. + * + * @param awsRequest the aws service update resource request + * @param proxyClient the aws service client to make the call + * @return aws service update resource response + */ + private UpdateUserProfileResponse updateResource( + final UpdateUserProfileRequest awsRequest, + final ProxyClient proxyClient + ) { + UpdateUserProfileResponse response = null; + try { + response = proxyClient.injectCredentialsAndInvokeV2(awsRequest, proxyClient.client()::updateUserProfile); + } catch (final AwsServiceException e) { + Translator.throwCfnException(Action.UPDATE.toString(), ResourceModel.TYPE_NAME, awsRequest.userProfileName(), e); + } + return response; + } + + /** + * Ensure resource has moved from pending to terminal state. + * + * @param updateUserProfileRequest the aws service update resource request + * @param proxyClient the aws service client to make the call + * @return boolean indicating if the resource is stabilized + */ + private boolean stabilizedOnUpdate( + final UpdateUserProfileRequest updateUserProfileRequest, + final UpdateUserProfileResponse updateUserProfileResponse, + final ProxyClient proxyClient, + final ResourceModel model, + final CallbackContext callbackContext) { + final UserProfileStatus UserProfileState = proxyClient.injectCredentialsAndInvokeV2( + TranslatorForRequest.translateToReadRequest(model), + proxyClient.client()::describeUserProfile).status(); + + switch (UserProfileState) { + case IN_SERVICE: + logger.log(String.format("%s [%s] has been stabilized with state %s during update operation.", + ResourceModel.TYPE_NAME, model.getPrimaryIdentifier(), UserProfileState)); + return true; + case PENDING: + case UPDATING: + logger.log(String.format("%s [%s] is stabilizing during update.", ResourceModel.TYPE_NAME, model.getPrimaryIdentifier())); + return false; + default: + throw new CfnGeneralServiceException("Stabilizing during update of " + model.getPrimaryIdentifier()); + + } + } + + /** + * Build the Progress Event object from the update response. + * + * @param model resource model + * @param awsResponse aws service update resource response + * @return progressEvent indicating success + */ + private ProgressEvent constructResourceModelFromResponse( + final ResourceModel model, + final UpdateUserProfileResponse awsResponse) { + model.setUserProfileArn(awsResponse.userProfileArn()); + return ProgressEvent.defaultSuccessHandler(model); + } +} diff --git a/aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/AbstractTestBase.java b/aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/AbstractTestBase.java new file mode 100644 index 0000000..fad7274 --- /dev/null +++ b/aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/AbstractTestBase.java @@ -0,0 +1,67 @@ +package software.amazon.sagemaker.userprofile; + +import software.amazon.awssdk.awscore.AwsRequest; +import software.amazon.awssdk.awscore.AwsResponse; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Credentials; +import software.amazon.cloudformation.proxy.LoggerProxy; +import software.amazon.cloudformation.proxy.ProxyClient; + +import java.time.Instant; +import java.util.function.Function; + +public class AbstractTestBase { + protected static final String TEST_USER_PROFILE_NAME = "testUserProfileName"; + protected static final String TEST_USER_PROFILE_ARN = "testAppArn"; + protected static final String TEST_DOMAIN_ID = "testDomainId"; + protected static final String TEST_SSO_ID = "testSSOId"; + protected static final String TEST_SSO_VALUE = "testSSOValue"; + protected static final String TEST_STATUS = "testStatus"; + protected static final String TEST_INSTANCE_TYPE = "testInstanceType"; + protected static final String TEST_IMAGE_ARN = "testImageArn"; + protected static final String TEST_IMAGE_VERSION_ARN = "testImageVersionArn"; + protected static final String TEST_NB_OUTPUT = "testNBOutput"; + protected static final String TEST_S3_KMS = "testS3KMS"; + protected static final String TEST_S3_OUTPUT = "testS3Output"; + protected static final String TEST_SECURITY_GROUP = "testSecGroup"; + protected static final String TEST_ROLE = "testRole"; + protected static final String TEST_IMAGE_NAME = "testImgName"; + protected static final String TEST_APP_IMAGE_CONFIG_NAME = "testAppImageConfigName"; + protected static final int TEST_IMAGE_VERSION_NUMBER = 7; + protected static final String TEST_FAILURE_REASON = "testFailureReason"; + protected static final String TEST_ERROR_MESSAGE = "test error message"; + protected static final Instant TEST_TIME = Instant.now(); + protected static final Credentials MOCK_CREDENTIALS; + protected static final LoggerProxy logger; + + static { + MOCK_CREDENTIALS = new Credentials("accessKey", "secretKey", "token"); + logger = new LoggerProxy(); + } + static ProxyClient MOCK_PROXY( + final AmazonWebServicesClientProxy proxy, + final SageMakerClient sagemakerClient) { + return new ProxyClient() { + @Override + public ResponseT + injectCredentialsAndInvokeV2(RequestT request, Function requestFunction) { + return proxy.injectCredentialsAndInvokeV2(request, requestFunction); + } + + @Override + public SageMakerClient client() { + return sagemakerClient; + } + }; + } + + protected static ResourceModel getRequestResourceModel() { + return ResourceModel.builder() + .userProfileName(TEST_USER_PROFILE_NAME) + .singleSignOnUserIdentifier(TEST_SSO_ID) + .singleSignOnUserValue(TEST_SSO_VALUE) + .domainId(TEST_DOMAIN_ID) + .build(); + } +} \ No newline at end of file diff --git a/aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/CreateHandlerTest.java b/aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/CreateHandlerTest.java new file mode 100644 index 0000000..f1da4dc --- /dev/null +++ b/aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/CreateHandlerTest.java @@ -0,0 +1,297 @@ +package software.amazon.sagemaker.userprofile; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsErrorDetails; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.CreateUserProfileRequest; +import software.amazon.awssdk.services.sagemaker.model.CreateUserProfileResponse; +import software.amazon.awssdk.services.sagemaker.model.DescribeUserProfileRequest; +import software.amazon.awssdk.services.sagemaker.model.DescribeUserProfileResponse; +import software.amazon.awssdk.services.sagemaker.model.ResourceInUseException; +import software.amazon.awssdk.services.sagemaker.model.ResourceLimitExceededException; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.awssdk.services.sagemaker.model.UserProfileStatus; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnInvalidRequestException; +import software.amazon.cloudformation.exceptions.CfnNotStabilizedException; +import software.amazon.cloudformation.exceptions.CfnServiceLimitExceededException; +import software.amazon.cloudformation.exceptions.ResourceAlreadyExistsException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class CreateHandlerTest extends software.amazon.sagemaker.userprofile.AbstractTestBase { + + private final ResourceModel REQUEST_MODEL = ResourceModel.builder() + .userProfileName(TEST_USER_PROFILE_NAME) + .domainId(TEST_DOMAIN_ID) + .build(); + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + SageMakerClient sdkClient; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + sdkClient = mock(SageMakerClient.class); + proxyClient = MOCK_PROXY(proxy, sdkClient); + } + + @Test + public void testCreateHandler_SimpleSuccess() { + final DescribeUserProfileResponse describeResponse = DescribeUserProfileResponse.builder() + .userProfileName(TEST_USER_PROFILE_NAME) + .domainId(TEST_DOMAIN_ID) + .status(UserProfileStatus.IN_SERVICE) + .build(); + + final CreateUserProfileResponse createResponse = CreateUserProfileResponse.builder() + .userProfileArn(TEST_USER_PROFILE_ARN) + .build(); + + when(proxyClient.client().describeUserProfile(any(DescribeUserProfileRequest.class))) + .thenReturn(describeResponse); + when(proxyClient.client().createUserProfile(any(CreateUserProfileRequest.class))) + .thenReturn(createResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + final ProgressEvent response = invokeHandleRequest(request); + + ResourceModel expectedModelFromResponse = ResourceModel.builder() + .domainId(TEST_DOMAIN_ID) + .userProfileArn(TEST_USER_PROFILE_ARN) + .userProfileName(TEST_USER_PROFILE_NAME) + .build(); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isEqualTo(expectedModelFromResponse); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testCreateHandler_InvalidRequestArn() { + final ResourceModel invalidModel = ResourceModel.builder() + .userProfileArn(TEST_USER_PROFILE_ARN) + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(invalidModel) + .build(); + + Exception exception = assertThrows( CfnInvalidRequestException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.InvalidRequest.getMessage(), + "The following property 'UserProfileArn' is not allowed to configured.")); + } + + @Test + public void testCreateHandler_ServiceInternalException() { + final AwsServiceException serviceInternalException = SageMakerException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(500) + .build(); + + when(proxyClient.client().createUserProfile(any(CreateUserProfileRequest.class))) + .thenThrow(serviceInternalException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows( CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.CREATE)); + } + + @Test + public void testCreateHandler_ResourceAlreadyExists_Fails() { + final ResourceInUseException resourceInUseException = ResourceInUseException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(400) + .build(); + + when(proxyClient.client().createUserProfile(any(CreateUserProfileRequest.class))) + .thenThrow(resourceInUseException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows( ResourceAlreadyExistsException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.AlreadyExists.getMessage(), + ResourceModel.TYPE_NAME, TEST_USER_PROFILE_NAME)); + } + + @Test + public void testCreateHandler_ResourceLimitExceededException() { + final ResourceLimitExceededException resourceLimitExceededException = ResourceLimitExceededException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(400) + .build(); + + when(proxyClient.client().createUserProfile(any(CreateUserProfileRequest.class))) + .thenThrow(resourceLimitExceededException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows(CfnServiceLimitExceededException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.ServiceLimitExceeded.getMessage(), + ResourceModel.TYPE_NAME, TEST_ERROR_MESSAGE)); + } + + @Test + public void testCreateHandler_ValidationFailure() { + final AwsErrorDetails awsErrorDetails = + AwsErrorDetails.builder().errorCode("ValidationError").errorMessage(TEST_ERROR_MESSAGE).build(); + + final AwsServiceException validationFailureException = SageMakerException.builder() + .awsErrorDetails(awsErrorDetails) + .build(); + + when(proxyClient.client().createUserProfile(any(CreateUserProfileRequest.class))) + .thenThrow(validationFailureException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows( CfnInvalidRequestException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.InvalidRequest.getMessage(), + TEST_ERROR_MESSAGE)); + } + + @Test + public void testCreateHandler_NoExceptionMessage() { + final AwsServiceException someException = SageMakerException.builder() + .statusCode(400) + .build(); + + when(proxyClient.client().createUserProfile(any(CreateUserProfileRequest.class))) + .thenThrow(someException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows( CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.CREATE)); + } + + @Test + public void testCreateHandler_VerifyStabilization_InService() { + final DescribeUserProfileResponse firstDescribeResponse = + DescribeUserProfileResponse.builder() + .userProfileArn(TEST_USER_PROFILE_ARN) + .userProfileName(TEST_USER_PROFILE_NAME) + .domainId(TEST_DOMAIN_ID) + .status(UserProfileStatus.PENDING) + .build(); + + final DescribeUserProfileResponse secondDescribeResponse = + DescribeUserProfileResponse.builder() + .userProfileArn(TEST_USER_PROFILE_ARN) + .userProfileName(TEST_USER_PROFILE_NAME) + .domainId(TEST_DOMAIN_ID) + .status(UserProfileStatus.IN_SERVICE) + .build(); + + final CreateUserProfileResponse createUserProfileResponse = CreateUserProfileResponse.builder() + .userProfileArn(TEST_USER_PROFILE_ARN) + .build(); + + when(proxyClient.client().describeUserProfile(any(DescribeUserProfileRequest.class))) + .thenReturn(firstDescribeResponse).thenReturn(secondDescribeResponse); + when(proxyClient.client().createUserProfile(any(CreateUserProfileRequest.class))) + .thenReturn(createUserProfileResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + final ProgressEvent response = invokeHandleRequest(request); + + ResourceModel expectedModelFromResponse = ResourceModel.builder() + .userProfileArn(TEST_USER_PROFILE_ARN) + .userProfileName(TEST_USER_PROFILE_NAME) + .domainId(TEST_DOMAIN_ID) + .build(); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isEqualTo(expectedModelFromResponse); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testCreateHandler_VerifyStabilization_Failed() { + final DescribeUserProfileResponse firstDescribeResponse = + DescribeUserProfileResponse.builder() + .status(UserProfileStatus.PENDING) + .build(); + + final DescribeUserProfileResponse secondDescribeResponse = + DescribeUserProfileResponse.builder() + .status(UserProfileStatus.FAILED) + .build(); + + final CreateUserProfileResponse createUserProfileResponse = CreateUserProfileResponse.builder().build(); + + when(proxyClient.client().describeUserProfile(any(DescribeUserProfileRequest.class))) + .thenReturn(firstDescribeResponse).thenReturn(secondDescribeResponse); + when(proxyClient.client().createUserProfile(any(CreateUserProfileRequest.class))) + .thenReturn(createUserProfileResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(REQUEST_MODEL) + .build(); + + Exception exception = assertThrows(CfnNotStabilizedException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()). + isEqualTo(String.format(HandlerErrorCode.NotStabilized.getMessage(), ResourceModel.TYPE_NAME, TEST_USER_PROFILE_NAME)); + } + + private ProgressEvent invokeHandleRequest(ResourceHandlerRequest request) { + final CreateHandler handler = new CreateHandler(); + return handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + } +} \ No newline at end of file diff --git a/aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/DeleteHandlerTest.java b/aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/DeleteHandlerTest.java new file mode 100644 index 0000000..6dd37ac --- /dev/null +++ b/aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/DeleteHandlerTest.java @@ -0,0 +1,195 @@ +package software.amazon.sagemaker.userprofile; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.DeleteUserProfileRequest; +import software.amazon.awssdk.services.sagemaker.model.DeleteUserProfileResponse; +import software.amazon.awssdk.services.sagemaker.model.DescribeUserProfileRequest; +import software.amazon.awssdk.services.sagemaker.model.DescribeUserProfileResponse; +import software.amazon.awssdk.services.sagemaker.model.ResourceInUseException; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.awssdk.services.sagemaker.model.UserProfileStatus; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.exceptions.CfnNotStabilizedException; +import software.amazon.cloudformation.exceptions.CfnResourceConflictException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DeleteHandlerTest extends software.amazon.sagemaker.userprofile.AbstractTestBase { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + SageMakerClient sdkClient; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + sdkClient = mock(SageMakerClient.class); + proxyClient = MOCK_PROXY(proxy, sdkClient); + } + + @Test + public void testDeleteHandler_SimpleSuccess() { + final DeleteUserProfileResponse deleteUserProfileResponse = DeleteUserProfileResponse.builder().build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + when(proxyClient.client().describeUserProfile(any(DescribeUserProfileRequest.class))) + .thenThrow(ResourceNotFoundException.class); + when(proxyClient.client().deleteUserProfile(any(DeleteUserProfileRequest.class))) + .thenReturn(deleteUserProfileResponse); + + final ProgressEvent response = invokeHandleRequest(request); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo((OperationStatus.SUCCESS)); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + assertThat(response.getResourceModel()).isNull(); + } + + @Test + public void testDeleteHandler_ServiceInternalException() { + final AwsServiceException serviceInternalException = SageMakerException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(500) + .build(); + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + when(proxyClient.client().deleteUserProfile(any(DeleteUserProfileRequest.class))) + .thenThrow(serviceInternalException); + + Exception exception = assertThrows(CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.DELETE)); + } + + @Test + public void testDeleteHandler_ResourceInUse_Fails() { + when(proxyClient.client().deleteUserProfile(any(DeleteUserProfileRequest.class))) + .thenThrow(ResourceInUseException.class); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows(CfnResourceConflictException.class, () -> invokeHandleRequest(request)); + + final String primaryIdentifier = String.format("%s|%s", TEST_DOMAIN_ID, TEST_USER_PROFILE_NAME); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.ResourceConflict.getMessage(), + ResourceModel.TYPE_NAME, primaryIdentifier, null)); + } + + @Test + public void testDeleteHandler_ResourceDoesNotExists_Fails() { + when(proxyClient.client().deleteUserProfile(any(DeleteUserProfileRequest.class))) + .thenThrow(ResourceNotFoundException.class); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows(CfnNotFoundException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.NotFound.getMessage(), + ResourceModel.TYPE_NAME, TEST_USER_PROFILE_NAME)); + } + + @Test + public void testDeleteHandler_VerifyStabilization_SuccessfulDelete() { + final DescribeUserProfileResponse firstDescribeResponse = + DescribeUserProfileResponse.builder() + .userProfileName(TEST_USER_PROFILE_NAME) + .domainId(TEST_DOMAIN_ID) + .creationTime(TEST_TIME) + .lastModifiedTime(TEST_TIME) + .status(UserProfileStatus.DELETING) + .build(); + + final DeleteUserProfileResponse deleteUserProfileResponse = DeleteUserProfileResponse.builder() + .build(); + + when(proxyClient.client().describeUserProfile(any(DescribeUserProfileRequest.class))) + .thenReturn(firstDescribeResponse).thenThrow(ResourceNotFoundException.class); + when(proxyClient.client().deleteUserProfile(any(DeleteUserProfileRequest.class))) + .thenReturn(deleteUserProfileResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + final ProgressEvent response = invokeHandleRequest(request); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testDeleteHandler_VerifyStabilization_ResourceNotDeleted() { + final DescribeUserProfileResponse firstDescribeResponse =DescribeUserProfileResponse.builder() + .status(UserProfileStatus.PENDING) + .build(); + + final DescribeUserProfileResponse secondDescribeResponse = DescribeUserProfileResponse.builder() + .status(UserProfileStatus.FAILED) + .build(); + + final DeleteUserProfileResponse deleteUserProfileResponse = DeleteUserProfileResponse.builder() + .build(); + + when(proxyClient.client().describeUserProfile(any(DescribeUserProfileRequest.class))) + .thenReturn(firstDescribeResponse).thenReturn(secondDescribeResponse); + when(proxyClient.client().deleteUserProfile(any(DeleteUserProfileRequest.class))) + .thenReturn(deleteUserProfileResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows( CfnNotStabilizedException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.NotStabilized.getMessage(), + ResourceModel.TYPE_NAME, TEST_USER_PROFILE_NAME)); + } + + private ProgressEvent invokeHandleRequest(ResourceHandlerRequest request) { + final DeleteHandler handler = new DeleteHandler(); + return handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + } +} diff --git a/aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/ListHandlerTest.java b/aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/ListHandlerTest.java new file mode 100644 index 0000000..bb930c4 --- /dev/null +++ b/aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/ListHandlerTest.java @@ -0,0 +1,141 @@ +package software.amazon.sagemaker.userprofile; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsErrorDetails; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.ListUserProfilesRequest; +import software.amazon.awssdk.services.sagemaker.model.ListUserProfilesResponse; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.awssdk.services.sagemaker.model.UserProfileDetails; +import software.amazon.cloudformation.exceptions.CfnServiceInternalErrorException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class ListHandlerTest extends software.amazon.sagemaker.userprofile.AbstractTestBase { + + private static final String OPERATION = "SageMaker::ListUserProfiles"; + public static final String TEST_TOKEN = "testToken"; + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + SageMakerClient sdkClient; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + sdkClient = mock(SageMakerClient.class); + proxyClient = MOCK_PROXY(proxy, sdkClient); + } + + @Test + public void testListHandler_SimpleSuccess() { + final UserProfileDetails userProfile = UserProfileDetails.builder() + .domainId(TEST_DOMAIN_ID) + .userProfileName(TEST_USER_PROFILE_NAME) + .build(); + + final ListUserProfilesResponse listResponse = ListUserProfilesResponse.builder() + .userProfiles(userProfile) + .nextToken(TEST_TOKEN) + .build(); + + when(proxyClient.client().listUserProfiles(any(ListUserProfilesRequest.class))) + .thenReturn(listResponse); + + final ResourceModel expectedResourceModel = ResourceModel.builder() + .userProfileName(TEST_USER_PROFILE_NAME) + .domainId(TEST_DOMAIN_ID) + .build(); + + List expectedModels = new ArrayList(); + expectedModels.add(expectedResourceModel); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + final ProgressEvent response = invokeHandleRequest(request); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isNull(); + assertThat(response.getResourceModels()).isEqualTo(expectedModels); + assertThat(response.getNextToken()).isEqualTo(TEST_TOKEN); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testListHandler_SimpleSuccess_NoUserProfilesExist() { + final ListUserProfilesResponse listResponse = ListUserProfilesResponse.builder() + .userProfiles(Collections.emptyList()) + .nextToken(null) + .build(); + + when(proxyClient.client().listUserProfiles(any(ListUserProfilesRequest.class))) + .thenReturn(listResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + final ProgressEvent response = invokeHandleRequest(request); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isNull(); + assertThat(response.getResourceModels()).isEqualTo(Collections.emptyList()); + assertThat(response.getNextToken()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testListHandler_ServiceInternalException() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("InternalError").errorMessage(OPERATION).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + when(proxyClient.client().listUserProfiles(any(ListUserProfilesRequest.class))) + .thenThrow(ex); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows(CfnServiceInternalErrorException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.ServiceInternalError.getMessage(), + OPERATION)); + } + + private ProgressEvent invokeHandleRequest(ResourceHandlerRequest request) { + final ListHandler handler = new ListHandler(); + return handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + } +} \ No newline at end of file diff --git a/aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/ReadHandlerTest.java b/aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/ReadHandlerTest.java new file mode 100644 index 0000000..ef85e9c --- /dev/null +++ b/aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/ReadHandlerTest.java @@ -0,0 +1,235 @@ +package software.amazon.sagemaker.userprofile; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.CustomImage; +import software.amazon.awssdk.services.sagemaker.model.DescribeUserProfileRequest; +import software.amazon.awssdk.services.sagemaker.model.DescribeUserProfileResponse; +import software.amazon.awssdk.services.sagemaker.model.JupyterServerAppSettings; +import software.amazon.awssdk.services.sagemaker.model.KernelGatewayAppSettings; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.awssdk.services.sagemaker.model.ResourceSpec; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.awssdk.services.sagemaker.model.SharingSettings; +import software.amazon.awssdk.services.sagemaker.model.UserSettings; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.time.Duration; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class ReadHandlerTest extends software.amazon.sagemaker.userprofile.AbstractTestBase { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + SageMakerClient sdkClient; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + sdkClient = mock(SageMakerClient.class); + proxyClient = MOCK_PROXY(proxy, sdkClient); + } + + @Test + public void testReadHandler_SimpleSuccess() { + final software.amazon.awssdk.services.sagemaker.model.SharingSettings sharingSettings = SharingSettings.builder() + .notebookOutputOption(TEST_NB_OUTPUT) + .s3KmsKeyId(TEST_S3_KMS) + .s3OutputPath(TEST_S3_OUTPUT) + .build(); + + final software.amazon.awssdk.services.sagemaker.model.CustomImage customImage = CustomImage.builder() + .imageName(TEST_IMAGE_NAME) + .imageVersionNumber(TEST_IMAGE_VERSION_NUMBER) + .appImageConfigName(TEST_APP_IMAGE_CONFIG_NAME) + .build(); + + final ResourceSpec resourceSpec = ResourceSpec.builder() + .instanceType(TEST_INSTANCE_TYPE) + .sageMakerImageArn(TEST_IMAGE_ARN) + .sageMakerImageVersionArn(TEST_IMAGE_VERSION_ARN) + .build(); + + final KernelGatewayAppSettings kernelGatewayAppSettings = KernelGatewayAppSettings.builder() + .customImages(customImage) + .defaultResourceSpec(resourceSpec) + .build(); + + final JupyterServerAppSettings jupyterServerAppSettings = JupyterServerAppSettings.builder() + .defaultResourceSpec(resourceSpec) + .build(); + + final UserSettings userSettings = UserSettings.builder() + .sharingSettings(sharingSettings) + .securityGroups(TEST_SECURITY_GROUP) + .executionRole(TEST_ROLE) + .kernelGatewayAppSettings(kernelGatewayAppSettings) + .jupyterServerAppSettings(jupyterServerAppSettings) + .build(); + + final DescribeUserProfileResponse describeResponse = DescribeUserProfileResponse.builder() + .userProfileArn(TEST_USER_PROFILE_ARN) + .userProfileName(TEST_USER_PROFILE_NAME) + .singleSignOnUserIdentifier(TEST_SSO_ID) + .singleSignOnUserValue(TEST_SSO_VALUE) + .domainId(TEST_DOMAIN_ID) + .userSettings(userSettings) + .creationTime(TEST_TIME) + .lastModifiedTime(TEST_TIME) + .failureReason(TEST_FAILURE_REASON) + .status(TEST_STATUS) + .build(); + + when(proxyClient.client().describeUserProfile(any(DescribeUserProfileRequest.class))) + .thenReturn(describeResponse); + + final software.amazon.sagemaker.userprofile.ResourceSpec expectedResourceSpec = + software.amazon.sagemaker.userprofile.ResourceSpec.builder() + .instanceType(TEST_INSTANCE_TYPE) + .sageMakerImageArn(TEST_IMAGE_ARN) + .sageMakerImageVersionArn(TEST_IMAGE_VERSION_ARN) + .build(); + + final software.amazon.sagemaker.userprofile.SharingSettings expectedSharingSettings = + software.amazon.sagemaker.userprofile.SharingSettings.builder() + .notebookOutputOption(TEST_NB_OUTPUT) + .s3KmsKeyId(TEST_S3_KMS) + .s3OutputPath(TEST_S3_OUTPUT) + .build(); + + final software.amazon.sagemaker.userprofile.CustomImage expectedCustomImage = + software.amazon.sagemaker.userprofile.CustomImage.builder() + .imageName(TEST_IMAGE_NAME) + .imageVersionNumber(TEST_IMAGE_VERSION_NUMBER) + .appImageConfigName(TEST_APP_IMAGE_CONFIG_NAME) + .build(); + + final software.amazon.sagemaker.userprofile.KernelGatewayAppSettings expectedKernelGatewayAppSettings = + software.amazon.sagemaker.userprofile.KernelGatewayAppSettings.builder() + .customImages(Collections.singletonList(expectedCustomImage)) + .defaultResourceSpec(expectedResourceSpec) + .build(); + + final software.amazon.sagemaker.userprofile.JupyterServerAppSettings expectedJupyterServerAppSettings = + software.amazon.sagemaker.userprofile.JupyterServerAppSettings.builder() + .defaultResourceSpec(expectedResourceSpec) + .build(); + + final software.amazon.sagemaker.userprofile.UserSettings expectedUserSettings = + software.amazon.sagemaker.userprofile.UserSettings.builder() + .sharingSettings(expectedSharingSettings) + .securityGroups(Collections.singletonList(TEST_SECURITY_GROUP)) + .executionRole(TEST_ROLE) + .kernelGatewayAppSettings(expectedKernelGatewayAppSettings) + .jupyterServerAppSettings(expectedJupyterServerAppSettings) + .build(); + + final ResourceModel expectedResourceModel = ResourceModel.builder() + .userProfileArn(TEST_USER_PROFILE_ARN) + .userProfileName(TEST_USER_PROFILE_NAME) + .singleSignOnUserIdentifier(TEST_SSO_ID) + .singleSignOnUserValue(TEST_SSO_VALUE) + .domainId(TEST_DOMAIN_ID) + .userSettings(expectedUserSettings) + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + final ProgressEvent response = invokeHandleRequest(request); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isEqualTo(expectedResourceModel); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + verify(proxyClient.client()).describeUserProfile(any(DescribeUserProfileRequest.class)); + } + + @Test + public void testReadHandler_ServiceInternalException() { + final AwsServiceException serviceInternalException = SageMakerException.builder() + .message("test error message") + .statusCode(500) + .build(); + + when(proxyClient.client().describeUserProfile(any(DescribeUserProfileRequest.class))) + .thenThrow(serviceInternalException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows( CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.READ)); + } + + @Test + public void testReadHandler_UserProfileDoesNotExist_Fails() { + final AwsServiceException resourceNotFoundException = AwsServiceException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(400) + .build(); + + when(proxyClient.client().describeUserProfile(any(DescribeUserProfileRequest.class))) + .thenThrow(resourceNotFoundException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows( CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.READ)); + } + + @Test + public void testReadHandler_ResourceNotFoundException() { + when(proxyClient.client().describeUserProfile(any(DescribeUserProfileRequest.class))) + .thenThrow(ResourceNotFoundException.class); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows(CfnNotFoundException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.NotFound.getMessage(), + ResourceModel.TYPE_NAME, TEST_USER_PROFILE_NAME)); + } + + private ProgressEvent invokeHandleRequest(ResourceHandlerRequest request) { + final ReadHandler handler = new ReadHandler(); + return handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + } +} \ No newline at end of file diff --git a/aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/TranslatorTest.java b/aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/TranslatorTest.java new file mode 100644 index 0000000..50cda50 --- /dev/null +++ b/aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/TranslatorTest.java @@ -0,0 +1,167 @@ +package software.amazon.sagemaker.userprofile; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.awscore.exception.AwsErrorDetails; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.model.ResourceLimitExceededException; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.cloudformation.exceptions.CfnAccessDeniedException; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnInvalidRequestException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.exceptions.CfnServiceInternalErrorException; +import software.amazon.cloudformation.exceptions.CfnServiceLimitExceededException; +import software.amazon.cloudformation.exceptions.CfnThrottlingException; +import software.amazon.cloudformation.proxy.HandlerErrorCode; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class TranslatorTest { + + public static final String TEST_OPERATION = "someOperation"; + public static final String RESOURCE_TYPE = "someResource"; + public static final String RESOURCE_NAME = "someType"; + public static final String ERROR_MESSAGE = "someError"; + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_UnauthorizedOperation() { + AwsErrorDetails errorDetails = + AwsErrorDetails.builder().errorCode("UnauthorizedOperation").errorMessage(ERROR_MESSAGE).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnAccessDeniedException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.AccessDenied.getMessage(), + ERROR_MESSAGE)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_InvalidParameter() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("InvalidParameter").build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnInvalidRequestException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_InvalidParameterValue() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("InvalidParameterValue").build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnInvalidRequestException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ValidationError() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("ValidationError").build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnInvalidRequestException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ValidationException() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("ValidationException").build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnInvalidRequestException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_InternalError() { + AwsErrorDetails errorDetails = + AwsErrorDetails.builder().errorCode("InternalError").errorMessage(ERROR_MESSAGE).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnServiceInternalErrorException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.ServiceInternalError.getMessage(), + ERROR_MESSAGE)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ServiceUnavailable() { + AwsErrorDetails errorDetails = + AwsErrorDetails.builder().errorCode("ServiceUnavailable").errorMessage(ERROR_MESSAGE).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnServiceInternalErrorException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.ServiceInternalError.getMessage(), + ERROR_MESSAGE)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ResourceLimitExceeded() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("ResourceLimitExceeded").build(); + AwsServiceException ex = ResourceLimitExceededException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnServiceLimitExceededException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ResourceNotFound() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("ResourceNotFound").build(); + AwsServiceException ex = ResourceNotFoundException.builder().awsErrorDetails(errorDetails).build(); + + assertThrows(CfnNotFoundException.class, () + -> Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_ThrottlingException() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("ThrottlingException").errorMessage(ERROR_MESSAGE).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnThrottlingException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.Throttling.getMessage(), + ERROR_MESSAGE)); + } + + @Test + public void testGetHandlerErrorCodeForSageMakerErrorCode_UnknownException() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().errorCode("Unknown").errorMessage(ERROR_MESSAGE).build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnGeneralServiceException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + ERROR_MESSAGE)); + } + + @Test + public void testGetHandlerError_NoErrorCode() { + AwsErrorDetails errorDetails = AwsErrorDetails.builder().build(); + AwsServiceException ex = SageMakerException.builder().awsErrorDetails(errorDetails).build(); + + Exception exception = assertThrows(CfnGeneralServiceException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + TEST_OPERATION)); + } + + @Test + public void testGetHandlerError_NoErrorDetails() { + AwsServiceException ex = SageMakerException.builder().build(); + + Exception exception = assertThrows(CfnGeneralServiceException.class, () -> + Translator.throwCfnException(TEST_OPERATION, RESOURCE_TYPE, RESOURCE_NAME, ex)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + TEST_OPERATION)); + } +} \ No newline at end of file diff --git a/aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/UpdateHandlerTest.java b/aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/UpdateHandlerTest.java new file mode 100644 index 0000000..e759ac9 --- /dev/null +++ b/aws-sagemaker-userprofile/src/test/java/software/amazon/sagemaker/userprofile/UpdateHandlerTest.java @@ -0,0 +1,233 @@ +package software.amazon.sagemaker.userprofile; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.sagemaker.SageMakerClient; +import software.amazon.awssdk.services.sagemaker.model.DescribeUserProfileRequest; +import software.amazon.awssdk.services.sagemaker.model.DescribeUserProfileResponse; +import software.amazon.awssdk.services.sagemaker.model.ResourceLimitExceededException; +import software.amazon.awssdk.services.sagemaker.model.ResourceNotFoundException; +import software.amazon.awssdk.services.sagemaker.model.SageMakerException; +import software.amazon.awssdk.services.sagemaker.model.UpdateUserProfileRequest; +import software.amazon.awssdk.services.sagemaker.model.UpdateUserProfileResponse; +import software.amazon.awssdk.services.sagemaker.model.UserProfileStatus; +import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.exceptions.CfnServiceLimitExceededException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class UpdateHandlerTest extends software.amazon.sagemaker.userprofile.AbstractTestBase { + + private static final String OPERATION = "SageMaker::UpdateUserProfile"; + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + SageMakerClient sdkClient; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + sdkClient = mock(SageMakerClient.class); + proxyClient = MOCK_PROXY(proxy, sdkClient); + } + + @Test + public void testUpdateHandler_SimpleSuccess() { + final DescribeUserProfileResponse describeUserProfileResponse = + DescribeUserProfileResponse.builder() + .userProfileName(TEST_USER_PROFILE_NAME) + .domainId(TEST_DOMAIN_ID) + .creationTime(TEST_TIME) + .lastModifiedTime(TEST_TIME) + .status(UserProfileStatus.IN_SERVICE) + .build(); + + final UpdateUserProfileResponse updateUserProfileResponse = + UpdateUserProfileResponse + .builder() + .userProfileArn(TEST_USER_PROFILE_ARN) + .build(); + + when(proxyClient.client().describeUserProfile(any(DescribeUserProfileRequest.class))) + .thenReturn(describeUserProfileResponse); + when(proxyClient.client().updateUserProfile(any(UpdateUserProfileRequest.class))) + .thenReturn(updateUserProfileResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + final ProgressEvent response = invokeHandleRequest(request); + + ResourceModel expectedModelFromResponse = ResourceModel.builder() + .userProfileArn(TEST_USER_PROFILE_ARN) + .userProfileName(TEST_USER_PROFILE_NAME) + .domainId(TEST_DOMAIN_ID) + .singleSignOnUserValue(TEST_SSO_VALUE) + .singleSignOnUserIdentifier(TEST_SSO_ID) + .build(); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isEqualTo(expectedModelFromResponse); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testUpdateHandler_ServiceInternalException() { + final AwsServiceException serviceInternalException = SageMakerException.builder() + .message(TEST_ERROR_MESSAGE) + .statusCode(500) + .build(); + + when(proxyClient.client().updateUserProfile(any(UpdateUserProfileRequest.class))) + .thenThrow(serviceInternalException); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows(CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + Action.UPDATE.toString())); + } + + @Test + public void testUpdateHandler_ResourceNotFoundException() { + when(proxyClient.client().updateUserProfile(any(UpdateUserProfileRequest.class))) + .thenThrow(ResourceNotFoundException.class); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows(CfnNotFoundException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.NotFound.getMessage(), + ResourceModel.TYPE_NAME, TEST_USER_PROFILE_NAME)); + } + + @Test + public void testUpdateHandler_ResourceLimitExceededException() { + when(proxyClient.client().updateUserProfile(any(UpdateUserProfileRequest.class))) + .thenThrow(ResourceLimitExceededException.class); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows(CfnServiceLimitExceededException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.ServiceLimitExceeded.getMessage(), + ResourceModel.TYPE_NAME, null)); + } + + @Test + public void testUpdateHandler_VerifyStabilization_SuccessfulUpdate() { + final DescribeUserProfileResponse firstDescribeResponse = + DescribeUserProfileResponse.builder() + .status(UserProfileStatus.UPDATING) + .build(); + + final DescribeUserProfileResponse secondDescribeResponse = + DescribeUserProfileResponse.builder() + .status(UserProfileStatus.IN_SERVICE) + .build(); + + final UpdateUserProfileResponse updateUserProfileResponse = + UpdateUserProfileResponse.builder() + .userProfileArn(TEST_USER_PROFILE_ARN) + .build(); + + when(proxyClient.client().describeUserProfile(any(DescribeUserProfileRequest.class))) + .thenReturn(firstDescribeResponse).thenReturn(secondDescribeResponse); + when(proxyClient.client().updateUserProfile(any(UpdateUserProfileRequest.class))) + .thenReturn(updateUserProfileResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + final ProgressEvent response = invokeHandleRequest(request); + + ResourceModel expectedModelFromResponse = ResourceModel.builder() + .userProfileArn(TEST_USER_PROFILE_ARN) + .userProfileName(TEST_USER_PROFILE_NAME) + .domainId(TEST_DOMAIN_ID) + .singleSignOnUserValue(TEST_SSO_VALUE) + .singleSignOnUserIdentifier(TEST_SSO_ID) + .build(); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isEqualTo(expectedModelFromResponse); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void testUpdateHandler_VerifyStabilization_Failed() { + final DescribeUserProfileResponse firstDescribeResponse = + DescribeUserProfileResponse.builder() + .userProfileName(TEST_USER_PROFILE_NAME) + .domainId(TEST_DOMAIN_ID) + .status(UserProfileStatus.UPDATING) + .build(); + + final DescribeUserProfileResponse secondDescribeResponse = + DescribeUserProfileResponse.builder() + .userProfileName(TEST_USER_PROFILE_NAME) + .domainId(TEST_DOMAIN_ID) + .status(UserProfileStatus.UPDATE_FAILED) + .build(); + + final UpdateUserProfileResponse updateUserProfileResponse = UpdateUserProfileResponse.builder() + .build(); + + when(proxyClient.client().describeUserProfile(any(DescribeUserProfileRequest.class))) + .thenReturn(firstDescribeResponse).thenReturn(secondDescribeResponse); + when(proxyClient.client().updateUserProfile(any(UpdateUserProfileRequest.class))) + .thenReturn(updateUserProfileResponse); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(getRequestResourceModel()) + .build(); + + Exception exception = assertThrows( CfnGeneralServiceException.class, () -> invokeHandleRequest(request)); + + assertThat(exception.getMessage()).isEqualTo(String.format(HandlerErrorCode.GeneralServiceException.getMessage(), + "Stabilizing during update of " + getRequestResourceModel().getPrimaryIdentifier())); + } + + private ProgressEvent invokeHandleRequest(ResourceHandlerRequest request) { + final UpdateHandler handler = new UpdateHandler(); + return handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + } +} \ No newline at end of file diff --git a/aws-sagemaker-userprofile/template.yml b/aws-sagemaker-userprofile/template.yml new file mode 100644 index 0000000..db1b484 --- /dev/null +++ b/aws-sagemaker-userprofile/template.yml @@ -0,0 +1,24 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Description: AWS SAM template for the AWS::SageMaker::UserProfile resource type + +Globals: + Function: + Timeout: 180 # docker start-up times can be long for SAM CLI + MemorySize: 256 + +Resources: + TypeFunction: + Type: AWS::Serverless::Function + Properties: + Handler: software.amazon.sagemaker.userprofile.HandlerWrapper::handleRequest + Runtime: java8 + CodeUri: ./target/aws-sagemaker-userprofile-handler-1.0-SNAPSHOT.jar + + TestEntrypoint: + Type: AWS::Serverless::Function + Properties: + Handler: software.amazon.sagemaker.userprofile.HandlerWrapper::testEntrypoint + Runtime: java8 + CodeUri: ./target/aws-sagemaker-userprofile-handler-1.0-SNAPSHOT.jar +