From d579cbcf478281f99e09260348150f21540f371f Mon Sep 17 00:00:00 2001 From: Anton Kukushkin Date: Tue, 20 Feb 2024 11:17:30 +0000 Subject: [PATCH 1/7] add sagemaker studio module Signed-off-by: Anton Kukushkin --- modules/sagemaker/sagemaker-studio/README.md | 127 ++++++++ modules/sagemaker/sagemaker-studio/app.py | 83 +++++ .../sagemaker/sagemaker-studio/coverage.ini | 3 + .../sagemaker-studio/deployspec.yaml | 46 +++ .../sm_studio/enable_sm_projects/index.py | 55 ++++ .../enable_sm_projects/requirements.txt | 2 + .../helper_constructs/__init__.py | 0 .../helper_constructs/sm_roles.py | 293 ++++++++++++++++++ .../sagemaker-studio/modulestack.yaml | 48 +++ .../sagemaker/sagemaker-studio/pyproject.toml | 35 +++ .../sagemaker-studio/requirements.txt | 4 + .../scripts/check_lcc_state.sh | 9 + .../scripts/delete-domains.py | 116 +++++++ .../sagemaker-studio/scripts/delete_efs.py | 82 +++++ .../scripts/on-jupyter-server-start.sh | 78 +++++ modules/sagemaker/sagemaker-studio/setup.cfg | 29 ++ modules/sagemaker/sagemaker-studio/stack.py | 202 ++++++++++++ .../sagemaker-studio/tests/__init__.py | 0 .../sagemaker-studio/tests/test_app.py | 53 ++++ .../sagemaker-studio/tests/test_stack.py | 64 ++++ .../update-domain-input.template.json | 13 + 21 files changed, 1342 insertions(+) create mode 100644 modules/sagemaker/sagemaker-studio/README.md create mode 100644 modules/sagemaker/sagemaker-studio/app.py create mode 100644 modules/sagemaker/sagemaker-studio/coverage.ini create mode 100644 modules/sagemaker/sagemaker-studio/deployspec.yaml create mode 100644 modules/sagemaker/sagemaker-studio/functions/sm_studio/enable_sm_projects/index.py create mode 100644 modules/sagemaker/sagemaker-studio/functions/sm_studio/enable_sm_projects/requirements.txt create mode 100644 modules/sagemaker/sagemaker-studio/helper_constructs/__init__.py create mode 100644 modules/sagemaker/sagemaker-studio/helper_constructs/sm_roles.py create mode 100644 modules/sagemaker/sagemaker-studio/modulestack.yaml create mode 100644 modules/sagemaker/sagemaker-studio/pyproject.toml create mode 100644 modules/sagemaker/sagemaker-studio/requirements.txt create mode 100755 modules/sagemaker/sagemaker-studio/scripts/check_lcc_state.sh create mode 100644 modules/sagemaker/sagemaker-studio/scripts/delete-domains.py create mode 100644 modules/sagemaker/sagemaker-studio/scripts/delete_efs.py create mode 100644 modules/sagemaker/sagemaker-studio/scripts/on-jupyter-server-start.sh create mode 100644 modules/sagemaker/sagemaker-studio/setup.cfg create mode 100644 modules/sagemaker/sagemaker-studio/stack.py create mode 100644 modules/sagemaker/sagemaker-studio/tests/__init__.py create mode 100644 modules/sagemaker/sagemaker-studio/tests/test_app.py create mode 100644 modules/sagemaker/sagemaker-studio/tests/test_stack.py create mode 100644 modules/sagemaker/sagemaker-studio/update-domain-input.template.json diff --git a/modules/sagemaker/sagemaker-studio/README.md b/modules/sagemaker/sagemaker-studio/README.md new file mode 100644 index 00000000..13eb025a --- /dev/null +++ b/modules/sagemaker/sagemaker-studio/README.md @@ -0,0 +1,127 @@ +# SageMaker studio Infrastructure + +This module contains the resources that are required to deploy the SageMaker Studio infrastructure. It defines the setup for Amazon SageMaker Studio Domain and creates SageMaker Studio User Profiles for Data Scientists and Lead Data Scientists. + +**NOTE** To effectively use this repository you would need to have a good understanding around AWS networking services, AWS CloudFormation and AWS CDK. +- [SageMaker studio Infrastructure](#sagemaker-studio-infrastructure) + - [SageMaker Studio Stack](#sagemaker-studio-stack) + - [Inputs and outputs:](#inputs-and-outputs) + - [Required inputs:](#required-inputs) + - [Optional Inputs:](#optional-inputs) + - [Outputs (module metadata):](#outputs-module-metadata) + - [Example Output:](#example-output) + - [Getting Started](#getting-started) + - [Prerequisites](#prerequisites) + - [Module Structure](#module-structure) + - [Troubleshooting](#troubleshooting) + +### SageMaker Studio Stack + +This stack handles the deployment of the following resources: + +1. SageMaker Studio Domain requires, along with +2. IAM roles which would be linked to SM Studio user profiles. User Profile creating process is managed by manifests files in `manifests/shared-infra/mlops-modules.yaml`. You can simply add new entries in the list to create a new user. The user will be linked to a role depending on which group you add them to (`data_science_users` or `lead_data_science_users`). + +``` + - name: data_science_users + value: + - data-scientist + - name: lead_data_science_users + value: + - lead-data-scientist +``` + +3. Default SageMaker Project Templates are also enabled on the account on the targeted region using a custom resource; the custom resource uses a lambda function, `functions/sm_studio/enable_sm_projects`, to make necessary SDK calls to both Amazon Service Catalog and Amazon SageMaker. + +## Inputs and outputs: +### Required inputs: + - `VPC_ID` + - `subnet_ids` +### Optional Inputs: + - `studio_domain_name` + - `studio_bucket_name` + - `app_image_config_name` - custom kernel app config name + - `image_name` - custom kernel image name + - `data_science_users` - a list of data science user names to create + - `lead_data_science_users` - a list of lead data science user names to create + - `retain_efs` - True | False -- if set to True, the EFS volume will persist after domain deletion. Default is True + - `enable_custom_sagemaker_projects` - True | False -- if set to True, custom sagemaker projects will be enabled for the data science and lead data science users. Default is False + +### Outputs (module metadata): + - `StudioDomainName` - the name of the domain created by Sagemaker Studio + - `StudioDomainId` - the Id of the domain created by Sagemaker Studio + - `StudioBucketName` - the Bucket (or prefix) given access to Sagemaker Studio + - `StudioDomainEFSId` - the EFS created by Sagemaker Studio + - `DataScientistRoleArn` + - `LeadDataScientistRoleArn` + - `SageMakerExecutionRoleArn` + +### Example Output: +```yaml +{ + "DataScientistRoleArn": "arn:aws:iam::XXXXXXXXXXXX:role/mlops-sagemaker-sage-smrolesdatascientistrole-DYPIVQ6NUSP9", + "LeadDataScientistRoleArn": "arn:aws:iam::XXXXXXXXXXXX:role/mlops-sagemaker-sage-smrolesleaddatascientist-V1YL0FQONH62", + "SageMakerExecutionRoleArn": "arn:aws:iam::XXXXXXXXXXXX:role/mlops-sagemaker-sage-smrolessagemakerstudioro-F6HGOUX0JGTI", + "StudioBucketName": "mlops-*", + "StudioDomainEFSId": "fs-0a550ea71ecac4978", + "StudioDomainId": "d-flfqmvy84hfq", + "StudioDomainName": "mlops-sagemaker-sagemaker-sagemaker-studio-studio-domain" +} +``` + +## Getting Started + +### Prerequisites + +This is an AWS CDK project written in Python 3.8. Here's what you need to have on your workstation before you can deploy this project. It is preferred to use a linux OS to be able to run all cli commands and avoid path issues. + +* [Node.js](https://nodejs.org/) +* [Python3.8](https://www.python.org/downloads/release/python-380/) or [Miniconda](https://docs.conda.io/en/latest/miniconda.html) +* [AWS CDK v2](https://aws.amazon.com/cdk/) +* [AWS CLI](https://aws.amazon.com/cli/) +* [Docker](https://docs.docker.com/desktop/) + +### Module Structure + +``` +├── functions <--- lambda functions and layers +│ └── sm_studio <--- sagemaker studio stack related lambda function +│ └── enable_sm_projects <--- lambda function to enable sagemaker projects on the account and links the IAM roles of the domain users (used as a custom resource) +├── helper constructs <--- helper CDK constructs +│ └── sm_roles.py <--- helper construct containing IAM roles for sagemaker studio users +├── scripts <--- helper scripts +│ └── check_lcc_state.sh <--- script to check if sagemaker studio lifecycle config needs an update +│ └── delete-domains.py <--- python helper script to delete sagemaker domains +│ └── delete_efs.py <--- python helper script to delete efs mounts +│ └── on-jupyter-server-start.sh <--- script that installs the idle notebook auto-checker jupyter server extension +├── tests <--- module unit tests +├── app.py <--- cdk application entrypoint +├── coverage.ini <--- test coverage tool parameters file +├── deployspec.yaml <--- file that defines deployment instructions +├── modulestack.yaml <--- cloudformation stack that contains permissions needed to deploy the module +├── pyproject.toml <--- build system requirements and settings file +├── README.md <--- module documentation markdown file +├── requirements.txt <--- cdk packages used in the stacks (must be installed) +├── stack.py <--- stack to create sagemaker studio domain along with related IAM roles and the domain users +├── update-domain-input.template.json <--- json template to update sagemaker domain lifecycle configs +``` +## Troubleshooting + + +* **Resource being used by another resource** + +This error is harder to track and would require some effort to trace where is the resource that we want to delete is being used and severe that dependency before running the destroy command again. + +**NOTE** You should just really follow CloudFormation error messages and debug from there as they would include details about which resource is causing the error and in some occasion information into what needs to happen in order to resolve it. + + +* **CDK version X instead of Y** + +This error relates to a new update to cdk so run `npm install -g aws-cdk` again to update your cdk to the latest version and then run the deployment step again for each account that your stacks are deployed. + +* **`cdk synth`** **not running** + +One of the following would solve the problem: + + * Docker is having an issue so restart your docker daemon + * Refresh your awscli credentials diff --git a/modules/sagemaker/sagemaker-studio/app.py b/modules/sagemaker/sagemaker-studio/app.py new file mode 100644 index 00000000..c1244008 --- /dev/null +++ b/modules/sagemaker/sagemaker-studio/app.py @@ -0,0 +1,83 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import json +import os +from typing import cast + +import aws_cdk +from aws_cdk import CfnOutput + +from stack import SagemakerStudioStack + +project_name = os.getenv("SEEDFARMER_PROJECT_NAME", "") +deployment_name = os.getenv("SEEDFARMER_DEPLOYMENT_NAME", "") +module_name = os.getenv("SEEDFARMER_MODULE_NAME", "") +app_prefix = f"{project_name}-{deployment_name}-{module_name}" + +DEFAULT_STUDIO_DOMAIN_NAME = f"{app_prefix}-studio-domain" +DEFAULT_STUDIO_BUCKET_NAME = f"{app_prefix}-bucket" +DEFAULT_CUSTOM_KERNEL_APP_CONFIG_NAME = None +DEFAULT_CUSTOM_KERNEL_IMAGE_NAME = None +DEFAULT_ENABLE_CUSTOM_SAGEMAKER_PROJECTS = False + + +def _param(name: str) -> str: + return f"SEEDFARMER_PARAMETER_{name}" + + +vpc_id = os.getenv(_param("VPC_ID")) +subnet_ids = json.loads(os.getenv(_param("SUBNET_IDS"), "[]")) +studio_domain_name = os.getenv(_param("STUDIO_DOMAIN_NAME"), DEFAULT_STUDIO_DOMAIN_NAME) +studio_bucket_name = os.getenv(_param("STUDIO_BUCKET_NAME"), DEFAULT_STUDIO_BUCKET_NAME) +app_image_config_name = os.getenv(_param("CUSTOM_KERNEL_APP_CONFIG_NAME"), DEFAULT_CUSTOM_KERNEL_APP_CONFIG_NAME) +image_name = os.getenv(_param("CUSTOM_KERNEL_IMAGE_NAME"), DEFAULT_CUSTOM_KERNEL_IMAGE_NAME) +enable_custom_sagemaker_projects = bool( + os.getenv(_param("ENABLE_CUSTOM_SAGEMAKER_PROJECTS"), DEFAULT_ENABLE_CUSTOM_SAGEMAKER_PROJECTS) +) + +environment = aws_cdk.Environment( + account=os.environ["CDK_DEFAULT_ACCOUNT"], + region=os.environ["CDK_DEFAULT_REGION"], +) + +data_science_users = json.loads(os.getenv(_param("DATA_SCIENCE_USERS"), "[]")) +lead_data_science_users = json.loads(os.getenv(_param("LEAD_DATA_SCIENCE_USERS"), "[]")) + +app = aws_cdk.App() +stack = SagemakerStudioStack( + app, + app_prefix, + project_name=project_name, + deployment_name=deployment_name, + module_name=module_name, + vpc_id=cast(str, vpc_id), + subnet_ids=subnet_ids, + studio_domain_name=studio_domain_name, + studio_bucket_name=studio_bucket_name, + data_science_users=data_science_users, + lead_data_science_users=lead_data_science_users, + env=environment, + app_image_config_name=cast(str, app_image_config_name), + image_name=cast(str, image_name), + enable_custom_sagemaker_projects=enable_custom_sagemaker_projects, +) + + +CfnOutput( + scope=stack, + id="metadata", + value=stack.to_json_string( + { + "StudioDomainName": stack.studio_domain.domain_name, + "StudioDomainEFSId": stack.studio_domain.attr_home_efs_file_system_id, + "StudioDomainId": stack.studio_domain.attr_domain_id, + "StudioBucketName": studio_bucket_name, + "DataScientistRoleArn": stack.sm_roles.data_scientist_role.role_arn, + "LeadDataScientistRoleArn": stack.sm_roles.lead_data_scientist_role.role_arn, + "SageMakerExecutionRoleArn": stack.sm_roles.sagemaker_studio_role.role_arn, + } + ), +) + +app.synth() diff --git a/modules/sagemaker/sagemaker-studio/coverage.ini b/modules/sagemaker/sagemaker-studio/coverage.ini new file mode 100644 index 00000000..c3878739 --- /dev/null +++ b/modules/sagemaker/sagemaker-studio/coverage.ini @@ -0,0 +1,3 @@ +[run] +omit = + tests/* \ No newline at end of file diff --git a/modules/sagemaker/sagemaker-studio/deployspec.yaml b/modules/sagemaker/sagemaker-studio/deployspec.yaml new file mode 100644 index 00000000..a8ba6c0a --- /dev/null +++ b/modules/sagemaker/sagemaker-studio/deployspec.yaml @@ -0,0 +1,46 @@ +publishGenericEnvVariables: True +deploy: + phases: + install: + commands: + - npm install -g aws-cdk@2.89.0 + - pip install -r requirements.txt + - apt-get install gettext-base + build: + commands: + - LCC_CONTENT=`openssl base64 -A -in scripts/on-jupyter-server-start.sh` + - export LCC_CONTENT=$LCC_CONTENT + - aws sagemaker create-studio-lifecycle-config --studio-lifecycle-config-name $SEEDFARMER_PARAMETER_SERVER_LIFECYCLE_NAME --studio-lifecycle-config-content $LCC_CONTENT --studio-lifecycle-config-app-type JupyterServer || true + - export LCC_ARN=$(aws sagemaker describe-studio-lifecycle-config --studio-lifecycle-config-name $SEEDFARMER_PARAMETER_SERVER_LIFECYCLE_NAME | jq -r ."StudioLifecycleConfigArn") + - echo $LCC_ARN + - ./scripts/check_lcc_state.sh + - cdk deploy --require-approval never --progress events --app "python app.py" --outputs-file ./cdk-exports.json + - cat cdk-exports.json + # Export metadata + - seedfarmer metadata convert -f cdk-exports.json || true + - export SEEDFARMER_MODULE_METADATA=$(cat SEEDFARMER_MODULE_METADATA) + - export DOMAIN_ID=$(echo ${SEEDFARMER_MODULE_METADATA} | jq -r ".StudioDomainId") + - echo $DOMAIN_ID + # Update SageMaker domain lifecycle config + - envsubst < "update-domain-input.template.json" > "update-domain-input.json" + - aws sagemaker update-domain --cli-input-json file://update-domain-input.json +destroy: + phases: + install: + commands: + - npm install -g aws-cdk@2.89.0 + - pip install -r requirements.txt + build: + commands: + - cdk destroy --force --app "python app.py" + - export EFS_ID=$(echo ${SEEDFARMER_MODULE_METADATA} | jq -r ".StudioDomainEFSId") + - export DOMAIN_ID=$(echo ${SEEDFARMER_MODULE_METADATA} | jq -r ".StudioDomainId") + - RETAIN_EFS=$(echo $SEEDFARMER_PARAMETER_RETAIN_EFS | tr '[:lower:]' '[:upper:]') + - echo $RETAIN_EFS + - echo $EFS_ID + - echo $DOMAIN_ID + - > + if [[ $RETAIN_EFS == "FALSE" ]]; then + echo "DELETING EFS" + python scripts/delete_efs.py ${EFS_ID} ${DOMAIN_ID} || true + fi; diff --git a/modules/sagemaker/sagemaker-studio/functions/sm_studio/enable_sm_projects/index.py b/modules/sagemaker/sagemaker-studio/functions/sm_studio/enable_sm_projects/index.py new file mode 100644 index 00000000..0b532487 --- /dev/null +++ b/modules/sagemaker/sagemaker-studio/functions/sm_studio/enable_sm_projects/index.py @@ -0,0 +1,55 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import boto3 +import cfnresponse +from botocore.exceptions import ClientError + +sm_client = boto3.client("sagemaker") +sc_client = boto3.client("servicecatalog") + + +def handler(event, context): + try: + if "RequestType" in event and event["RequestType"] in {"Create", "Update"}: + properties = event["ResourceProperties"] + roles = properties.get("ExecutionRoles", []) + + for role in roles: + enable_sm_projects(role) + + cfnresponse.send(event, context, cfnresponse.SUCCESS, {}, "") + except ClientError as exception: + print(exception) + cfnresponse.send( + event, + context, + cfnresponse.FAILED, + {}, + physicalResourceId=event.get("PhysicalResourceId"), + ) + + +def enable_sm_projects(studio_role_arn): + # enable Project on account level (accepts portfolio share) + response = sm_client.enable_sagemaker_servicecatalog_portfolio() + + print(response) + + # associate studio role with portfolio + response = sc_client.list_accepted_portfolio_shares() + + print(response) + + portfolio_id = "" + + for portfolio in response["PortfolioDetails"]: + if portfolio["ProviderName"] == "Amazon SageMaker": + portfolio_id = portfolio["Id"] + break + + response = sc_client.associate_principal_with_portfolio( + PortfolioId=portfolio_id, PrincipalARN=studio_role_arn, PrincipalType="IAM" + ) + + print(response) diff --git a/modules/sagemaker/sagemaker-studio/functions/sm_studio/enable_sm_projects/requirements.txt b/modules/sagemaker/sagemaker-studio/functions/sm_studio/enable_sm_projects/requirements.txt new file mode 100644 index 00000000..c5c9aa44 --- /dev/null +++ b/modules/sagemaker/sagemaker-studio/functions/sm_studio/enable_sm_projects/requirements.txt @@ -0,0 +1,2 @@ +cfnresponse +urllib3<2 # Lock to version before braking change to urllib diff --git a/modules/sagemaker/sagemaker-studio/helper_constructs/__init__.py b/modules/sagemaker/sagemaker-studio/helper_constructs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/sagemaker/sagemaker-studio/helper_constructs/sm_roles.py b/modules/sagemaker/sagemaker-studio/helper_constructs/sm_roles.py new file mode 100644 index 00000000..4e206646 --- /dev/null +++ b/modules/sagemaker/sagemaker-studio/helper_constructs/sm_roles.py @@ -0,0 +1,293 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +from typing import Any + +from aws_cdk import Aws +from aws_cdk import aws_iam as iam +from constructs import Construct + + +class SMRoles(Construct): + def __init__( + self, + scope: Construct, + construct_id: str, + s3_bucket_prefix: str, + env: str, + **kwargs: Any, + ) -> None: + super().__init__(scope, construct_id, **kwargs) + cdk_deploy_policy = iam.Policy( + self, + "cdk_deploy_policy", + statements=[ + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=[ + "cloudformation:*", + ], + resources=["*"], + ), + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=["iam:PassRole"], + resources=[f"arn:aws:iam::{Aws.ACCOUNT_ID}:role/cdk*"], + ), + ], + ) + sm_deny_policy = iam.Policy( + self, + "sm-deny-policy", + statements=[ + iam.PolicyStatement( + effect=iam.Effect.DENY, + actions=[ + "sagemaker:CreateProject", + ], + resources=["*"], + ), + iam.PolicyStatement( + effect=iam.Effect.DENY, + actions=["sagemaker:UpdateModelPackage"], + resources=["*"], + ), + ], + ) + + services_policy = iam.Policy( + self, + "services-policy", + statements=[ + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=[ + "lambda:Create*", + "lambda:Update*", + "lambda:Invoke*", + ], + resources=["*"], + ), + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=[ + "sagemaker:ListTags", + ], + resources=["*"], + ), + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=[ + "codecommit:GitPull", + "codecommit:GitPush", + "codecommit:*Branch*", + "codecommit:*PullRequest*", + "codecommit:*Commit*", + "codecommit:GetDifferences", + "codecommit:GetReferences", + "codecommit:GetRepository", + "codecommit:GetMerge*", + "codecommit:Merge*", + "codecommit:DescribeMergeConflicts", + "codecommit:*Comment*", + "codecommit:*File", + "codecommit:GetFolder", + "codecommit:GetBlob", + ], + resources=["*"], + ), + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=[ + "ecr:BatchGetImage", + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:GetRepositoryPolicy", + "ecr:DescribeRepositories", + "ecr:DescribeImages", + "ecr:ListImages", + "ecr:GetAuthorizationToken", + "ecr:GetLifecyclePolicy", + "ecr:GetLifecyclePolicyPreview", + "ecr:ListTagsForResource", + "ecr:DescribeImageScanFindings", + "ecr:CreateRepository", + "ecr:CompleteLayerUpload", + "ecr:UploadLayerPart", + "ecr:InitiateLayerUpload", + "ecr:PutImage", + ], + resources=["*"], + ), + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=[ + "servicecatalog:*", + ], + resources=["*"], + ), + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=[ + "cloudformation:CreateStack", + ], + resources=["*"], + ), + ], + ) + + kms_policy = iam.Policy( + self, + "kms-policy", + statements=[ + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=[ + "kms:CreateGrant", + "kms:Decrypt", + "kms:DescribeKey", + "kms:Encrypt", + "kms:ReEncrypt", + "kms:GenerateDataKey", + ], + resources=["*"], + ), + ], + ) + + s3_policy = iam.Policy( + self, + "s3-policy", + statements=[ + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=[ + "s3:AbortMultipartUpload", + "s3:DeleteObject", + "s3:Describe*", + "s3:GetObject", + "s3:PutBucket*", + "s3:PutObject", + "s3:PutObjectAcl", + "s3:GetBucketAcl", + "s3:GetBucketLocation", + ], + resources=[ + "arn:aws:s3:::{}*/*".format(s3_bucket_prefix), + "arn:aws:s3:::{}*".format(s3_bucket_prefix), + "arn:aws:s3:::cdk*/*", + "arn:aws:s3:::cdk*", + "arn:aws:s3:::sagemaker*", + "arn:aws:s3:::sagemaker*/*", + ], + ), + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=["s3:ListBucket"], + resources=[ + "arn:aws:s3:::{}*".format(s3_bucket_prefix), + "arn:aws:s3:::cdk*", + "arn:aws:s3:::sagemaker*", + ], + ), + iam.PolicyStatement( + effect=iam.Effect.DENY, + actions=["s3:DeleteBucket*"], + resources=["*"], + ), + ], + ) + + # create role for each persona + + # role for Data Scientist persona + self.data_scientist_role = iam.Role( + self, + "data-scientist-role", + assumed_by=iam.CompositePrincipal( + iam.ServicePrincipal("lambda.amazonaws.com"), + iam.ServicePrincipal("sagemaker.amazonaws.com"), + ), + managed_policies=[ + iam.ManagedPolicy.from_aws_managed_policy_name( + "AmazonSSMReadOnlyAccess", + ), + iam.ManagedPolicy.from_aws_managed_policy_name( + "AWSLambda_ReadOnlyAccess", + ), + iam.ManagedPolicy.from_aws_managed_policy_name("AWSCodeCommitReadOnly"), + iam.ManagedPolicy.from_aws_managed_policy_name( + "AmazonEC2ContainerRegistryReadOnly", + ), + iam.ManagedPolicy.from_aws_managed_policy_name( + "AmazonSageMakerFullAccess", + ), + ], + ) + + sm_deny_policy.attach_to_role(self.data_scientist_role) + services_policy.attach_to_role(self.data_scientist_role) + kms_policy.attach_to_role(self.data_scientist_role) + s3_policy.attach_to_role(self.data_scientist_role) + + # role for Lead Data Scientist persona + self.lead_data_scientist_role = iam.Role( + self, + "lead-data-scientist-role", + assumed_by=iam.CompositePrincipal( + iam.ServicePrincipal("lambda.amazonaws.com"), + iam.ServicePrincipal("sagemaker.amazonaws.com"), + ), + managed_policies=[ + iam.ManagedPolicy.from_aws_managed_policy_name( + "AmazonSSMReadOnlyAccess", + ), + iam.ManagedPolicy.from_aws_managed_policy_name( + "AWSLambda_ReadOnlyAccess", + ), + iam.ManagedPolicy.from_aws_managed_policy_name("AWSCodeCommitReadOnly"), + iam.ManagedPolicy.from_aws_managed_policy_name( + "AmazonEC2ContainerRegistryReadOnly", + ), + iam.ManagedPolicy.from_aws_managed_policy_name( + "AmazonSageMakerFullAccess", + ), + iam.ManagedPolicy.from_aws_managed_policy_name( + "AWSCodeCommitPowerUser", + ), + ], + ) + + services_policy.attach_to_role(self.lead_data_scientist_role) + kms_policy.attach_to_role(self.lead_data_scientist_role) + s3_policy.attach_to_role(self.lead_data_scientist_role) + cdk_deploy_policy.attach_to_role(self.lead_data_scientist_role) + + # default role for sagemaker persona + self.sagemaker_studio_role = iam.Role( + self, + "sagemaker-studio-role", + assumed_by=iam.CompositePrincipal( + iam.ServicePrincipal("lambda.amazonaws.com"), + iam.ServicePrincipal("sagemaker.amazonaws.com"), + ), + managed_policies=[ + iam.ManagedPolicy.from_aws_managed_policy_name( + "AmazonSSMReadOnlyAccess", + ), + iam.ManagedPolicy.from_aws_managed_policy_name( + "AWSLambda_ReadOnlyAccess", + ), + iam.ManagedPolicy.from_aws_managed_policy_name("AWSCodeCommitReadOnly"), + iam.ManagedPolicy.from_aws_managed_policy_name( + "AmazonEC2ContainerRegistryReadOnly", + ), + iam.ManagedPolicy.from_aws_managed_policy_name( + "AmazonSageMakerFullAccess", + ), + ], + ) + + services_policy.attach_to_role(self.sagemaker_studio_role) + kms_policy.attach_to_role(self.sagemaker_studio_role) + s3_policy.attach_to_role(self.sagemaker_studio_role) diff --git a/modules/sagemaker/sagemaker-studio/modulestack.yaml b/modules/sagemaker/sagemaker-studio/modulestack.yaml new file mode 100644 index 00000000..982bf01a --- /dev/null +++ b/modules/sagemaker/sagemaker-studio/modulestack.yaml @@ -0,0 +1,48 @@ +AWSTemplateFormatVersion: 2010-09-09 +Description: This stack deploys a Module specific IAM permissions + +Parameters: + # DeploymentName: + # Type: String + # Description: The name of the deployment + # ModuleName: + # Type: String + # Description: The name of the Module + RoleName: + Type: String + Description: The name of the IAM Role + +Resources: + Policy: + Type: "AWS::IAM::Policy" + Properties: + PolicyDocument: + Statement: + - Effect: Allow + Action: + - "sagemaker:*Domain" + - "sagemaker:*UserProfile" + - "sagemaker:*StudioLifecycleConfig" + Resource: "*" + - Effect: Allow + Action: + - "elasticfilesystem:DeleteAccessPoint" + - "elasticfilesystem:DeleteMountTarget" + - "elasticfilesystem:DeleteFileSystem" + Resource: + - !Sub "arn:aws:elasticfilesystem:${AWS::Region}:${AWS::AccountId}:file-system/*" + - !Sub "arn:aws:elasticfilesystem:${AWS::Region}:${AWS::AccountId}:access-point/*" + - Effect: Allow + Action: + - "elasticfilesystem:Describe*" + - "ec2:Describe*" + Resource: "*" + - Effect: Allow + Action: + - "ec2:DeleteSecurityGroup" + - "ec2:RevokeSecurityGroupIngress" + - "ec2:RevokeSecurityGroupEgress" + Resource: "*" + Version: 2012-10-17 + PolicyName: "idf-modulespecific-policy" + Roles: [!Ref RoleName] diff --git a/modules/sagemaker/sagemaker-studio/pyproject.toml b/modules/sagemaker/sagemaker-studio/pyproject.toml new file mode 100644 index 00000000..361877d5 --- /dev/null +++ b/modules/sagemaker/sagemaker-studio/pyproject.toml @@ -0,0 +1,35 @@ +[tool.black] +line-length = 120 +target-version = ["py36", "py37", "py38", "py39"] +exclude = ''' +/( + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | \.env + | _build + | buck-out + | build + | dist + | codeseeder.out +)/ +''' + +[tool.isort] +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +ensure_newline_before_comments = true +line_length = 120 +py_version = 38 +skip_gitignore = false + +[tool.pytest.ini_options] +addopts = "-v --cov=. --cov-report term --cov-config=coverage.ini --cov-fail-under=80" +pythonpath = [ + "." +] \ No newline at end of file diff --git a/modules/sagemaker/sagemaker-studio/requirements.txt b/modules/sagemaker/sagemaker-studio/requirements.txt new file mode 100644 index 00000000..8f3aefaf --- /dev/null +++ b/modules/sagemaker/sagemaker-studio/requirements.txt @@ -0,0 +1,4 @@ +aws-cdk-lib==2.89 +aws_cdk.aws_lambda_python_alpha==2.89.0a0 +constructs>=10.0.0,<11.0.0 +cdk-nag==2.27.87 diff --git a/modules/sagemaker/sagemaker-studio/scripts/check_lcc_state.sh b/modules/sagemaker/sagemaker-studio/scripts/check_lcc_state.sh new file mode 100755 index 00000000..06d344ef --- /dev/null +++ b/modules/sagemaker/sagemaker-studio/scripts/check_lcc_state.sh @@ -0,0 +1,9 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +CURRENT_LLC_CONTENT=$(aws sagemaker describe-studio-lifecycle-config --studio-lifecycle-config-name $SEEDFARMER_PARAMETER_SERVER_LIFECYCLE_NAME | jq -r ."StudioLifecycleConfigContent") + +if [ "$CURRENT_LLC_CONTENT" != "$LCC_CONTENT" ]; then + echo "Lifecycle configuration content needs to be updated, but lifecycle config $SEEDFARMER_PARAMETER_SERVER_LIFECYCLE_NAME already exists. Please manually remove the SageMaker studio LCC with name $SEEDFARMER_PARAMETER_SERVER_LIFECYCLE_NAME or name the configuration differently" + exit 1 +fi \ No newline at end of file diff --git a/modules/sagemaker/sagemaker-studio/scripts/delete-domains.py b/modules/sagemaker/sagemaker-studio/scripts/delete-domains.py new file mode 100644 index 00000000..5d667f3d --- /dev/null +++ b/modules/sagemaker/sagemaker-studio/scripts/delete-domains.py @@ -0,0 +1,116 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import argparse +import time + +import boto3 +from botocore.config import Config + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument( + "-r", + "--region", + type=str, + required=True, + help="AWS region for which the domains should be deleted", + ) + parser.add_argument( + "-p", + "--profile", + type=str, + required=True, + help="AWS profile to use for authentication", + ) + args = parser.parse_args() + region = args.region + aws_profile = args.profile + + conf = Config( + region_name=region, + ) + + accept_input = f"delete-{region}" + print(f"Are you certain you want to delete Sagemaker domain in: {region}. [{accept_input}]") + print("This action cannot be undone") + user_input = input() + + if user_input != accept_input: + print(f"Cancelled. If you wish to delete the domain, please enter {accept_input}") + exit(1) + else: + session = boto3.session.Session(profile_name=aws_profile) + client = session.client("sagemaker", config=conf) + + def get_domains(): + req = client.list_domains() + return req["Domains"] + + def get_apps(domainId): + req = client.list_apps(DomainIdEquals=domainId) + return req["Apps"] + + def get_user_profiles(domainId): + req = client.list_user_profiles( + DomainIdEquals=domainId, + ) + return req["UserProfiles"] + + def delete_app(app): + try: + print(app["Status"]) + if app["Status"] != "Deleted": + client.delete_app( + DomainId=app["DomainId"], + UserProfileName=app["UserProfileName"], + AppType=app["AppType"], + AppName=app["AppName"], + ) + else: + print("App already delted") + except Exception as e: + print(f"\tError deleting app: {e}") + + def delete_user_profile(profile): + try: + client.delete_user_profile( + DomainId=profile["DomainId"], + UserProfileName=profile["UserProfileName"], + ) + except Exception as e: + print(f"\tError deleting user_profile: {e}") + + def delete_domain(domainId): + try: + client.delete_domain(DomainId=domainId, RetentionPolicy={"HomeEfsFileSystem": "Delete"}) + except Exception as e: + print(f"\tError deleting domain: {e}") + + def wait_until_none(domainId, check): + items = check(domainId) + if items is not None and len(items) > 0: + not_already_deleted = [x for x in items if x["Status"] != "Deleted"] + if not_already_deleted is not None and len(not_already_deleted) > 0: + time.sleep(5) + wait_until_none(domainId, check) + else: + print("found items, but they are already deleted") + else: + print("No items, found, moving on...") + + for domain in get_domains(): + domainId = domain["DomainId"] + print(f"Getting apps for domain {domainId}") + apps = get_apps(domainId) + for app in apps: + print(f"Deleting app: {app['AppName']}") + # delete_app(app) + # wait_until_none(domainId, get_apps) + user_profiles = get_user_profiles(domainId) + for profile in user_profiles: + print(f"Deleting user profile: {profile['UserProfileName']}") + # delete_user_profile(profile) + # wait_until_none(domainId, get_user_profiles) + # delete_domain(domainId) + print(f"Done deleting domain {domainId}") diff --git a/modules/sagemaker/sagemaker-studio/scripts/delete_efs.py b/modules/sagemaker/sagemaker-studio/scripts/delete_efs.py new file mode 100644 index 00000000..32a0c0ed --- /dev/null +++ b/modules/sagemaker/sagemaker-studio/scripts/delete_efs.py @@ -0,0 +1,82 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import sys +import time +from typing import List + +import boto3 + +client_efs = boto3.client("efs") +client_ec2 = boto3.client("ec2") + + +def delete_mount_target(target: str) -> None: + client_efs.delete_mount_target(MountTargetId=target) + + +def delete_file_system(efs_id: str) -> None: + client_efs.delete_file_system(FileSystemId=efs_id) + + +def delete_security_groups(sg_ids: List[str]) -> None: + resp = client_ec2.describe_security_groups(GroupIds=sg_ids) + + for sg in resp["SecurityGroups"]: + if sg["IpPermissionsEgress"]: + client_ec2.revoke_security_group_egress(GroupId=sg["GroupId"], IpPermissions=sg["IpPermissionsEgress"]) + if sg["IpPermissions"]: + client_ec2.revoke_security_group_ingress(GroupId=sg["GroupId"], IpPermissions=sg["IpPermissions"]) + + for sg in resp["SecurityGroups"]: + client_ec2.delete_security_group(GroupId=sg["GroupId"]) + + +def get_mount_point_security_groups(target: str) -> List[str]: + resp = client_efs.describe_mount_target_security_groups(MountTargetId=target) + return resp["SecurityGroups"] + + +def get_security_groups(vpc_id: str, domain_id: str) -> List[str]: + resp = client_ec2.describe_security_groups( + Filters=[ + { + "Name": "vpc-id", + "Values": [ + vpc_id, + ], + }, + { + "Name": "group-name", + "Values": [ + f"security-group-for-inbound-nfs-{domain_id}", + f"security-group-for-outbound-nfs-{domain_id}", + ], + }, + ] + ) + sgs = [sg["GroupId"] for sg in resp["SecurityGroups"]] + return sgs + + +def process(efs_id: str, domain_id: str) -> None: + resp = client_efs.describe_mount_targets(FileSystemId=efs_id) + vpc_id = None + for target in resp["MountTargets"]: + mount_target_id = target["MountTargetId"] + vpc_id = target["VpcId"] + print(f"Deleting mount target {mount_target_id}") + delete_mount_target(mount_target_id) + + print("Sleeping to allow mount targts to delete") + time.sleep(35) + print(f"Deleting FileSystem {efs_id}") + delete_file_system(efs_id=efs_id) + sgs = get_security_groups(vpc_id=vpc_id, domain_id=domain_id) + delete_security_groups(sgs) + + +if __name__ == "__main__": + efs_id = sys.argv[1] + domain_id = sys.argv[2] + process(efs_id, domain_id) diff --git a/modules/sagemaker/sagemaker-studio/scripts/on-jupyter-server-start.sh b/modules/sagemaker/sagemaker-studio/scripts/on-jupyter-server-start.sh new file mode 100644 index 00000000..0a328db4 --- /dev/null +++ b/modules/sagemaker/sagemaker-studio/scripts/on-jupyter-server-start.sh @@ -0,0 +1,78 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +#!/bin/bash +# This script installs the idle notebook auto-checker server extension to SageMaker Studio +# The original extension has a lab extension part where users can set the idle timeout via a Jupyter Lab widget. +# In this version the script installs the server side of the extension only. The idle timeout +# can be set via a command-line script which will be also created by this create and places into the +# user's home folder +# +# Installing the server side extension does not require Internet connection (as all the dependencies are stored in the +# install tarball) and can be done via VPCOnly mode. + +set -eux + +# timeout in minutes +export TIMEOUT_IN_MINS=120 + +# Should already be running in user home directory, but just to check: +cd /home/sagemaker-user + +# By working in a directory starting with ".", we won't clutter up users' Jupyter file tree views +mkdir -p .auto-shutdown + +# Create the command-line script for setting the idle timeout +cat > .auto-shutdown/set-time-interval.sh << EOF +#!/opt/conda/bin/python +import json +import requests +TIMEOUT=${TIMEOUT_IN_MINS} +session = requests.Session() +# Getting the xsrf token first from Jupyter Server +response = session.get("http://localhost:8888/jupyter/default/tree") +# calls the idle_checker extension's interface to set the timeout value +response = session.post("http://localhost:8888/jupyter/default/sagemaker-studio-autoshutdown/idle_checker", + json={"idle_time": TIMEOUT, "keep_terminals": False}, + params={"_xsrf": response.headers['Set-Cookie'].split(";")[0].split("=")[1]}) +if response.status_code == 200: + print("Succeeded, idle timeout set to {} minutes".format(TIMEOUT)) +else: + print("Error!") + print(response.status_code) +EOF +chmod +x .auto-shutdown/set-time-interval.sh + +# "wget" is not part of the base Jupyter Server image, you need to install it first if needed to download the tarball +sudo yum install -y wget +# You can download the tarball from GitHub or alternatively, if you're using VPCOnly mode, you can host on S3 +wget -O .auto-shutdown/extension.tar.gz https://github.com/aws-samples/sagemaker-studio-auto-shutdown-extension/raw/main/sagemaker_studio_autoshutdown-0.1.5.tar.gz + +# Or instead, could serve the tarball from an S3 bucket in which case "wget" would not be needed: +# aws s3 --endpoint-url [S3 Interface Endpoint] cp s3://[tarball location] .auto-shutdown/extension.tar.gz + +# Installs the extension +cd .auto-shutdown +tar xzf extension.tar.gz +cd sagemaker_studio_autoshutdown-0.1.5 + +# Activate studio environment just for installing extension +export AWS_SAGEMAKER_JUPYTERSERVER_IMAGE="${AWS_SAGEMAKER_JUPYTERSERVER_IMAGE:-'jupyter-server'}" +if [ "$AWS_SAGEMAKER_JUPYTERSERVER_IMAGE" = "jupyter-server-3" ] ; then + eval "$(conda shell.bash hook)" + conda activate studio +fi; +pip install --no-dependencies --no-build-isolation -e . +jupyter serverextension enable --py sagemaker_studio_autoshutdown +if [ "$AWS_SAGEMAKER_JUPYTERSERVER_IMAGE" = "jupyter-server-3" ] ; then + conda deactivate +fi; + +# Restarts the jupyter server +nohup supervisorctl -c /etc/supervisor/conf.d/supervisord.conf restart jupyterlabserver + +# Waiting for 30 seconds to make sure the Jupyter Server is up and running +sleep 30 + +# Calling the script to set the idle-timeout and active the extension +/home/sagemaker-user/.auto-shutdown/set-time-interval.sh diff --git a/modules/sagemaker/sagemaker-studio/setup.cfg b/modules/sagemaker/sagemaker-studio/setup.cfg new file mode 100644 index 00000000..8995857f --- /dev/null +++ b/modules/sagemaker/sagemaker-studio/setup.cfg @@ -0,0 +1,29 @@ +[metadata] +license_files = + LICENSE + NOTICE + VERSION + +[flake8] +max-line-length = 120 +extend-ignore = E203, W503 +exclude = + .git, + __pycache__, + docs/source/conf.py, + old, + build, + dist, + .venv, + codeseeder.out, + bundle + tests + +[mypy] +python_version = 3.7 +strict = True +ignore_missing_imports = True +allow_untyped_decorators = True +exclude = + codeseeder.out/|example/|tests/|functions/sm_studio/enable_sm_projects/|scripts/ +warn_unused_ignores = False diff --git a/modules/sagemaker/sagemaker-studio/stack.py b/modules/sagemaker/sagemaker-studio/stack.py new file mode 100644 index 00000000..3ac796f1 --- /dev/null +++ b/modules/sagemaker/sagemaker-studio/stack.py @@ -0,0 +1,202 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +from typing import Any, List, cast + +import aws_cdk as core +from aws_cdk import Stack, Tags +from aws_cdk import aws_ec2 as ec2 +from aws_cdk import aws_iam as iam +from aws_cdk import aws_lambda as lambda_ +from aws_cdk import aws_sagemaker as sagemaker +from aws_cdk.aws_lambda_python_alpha import PythonFunction +from aws_cdk.custom_resources import Provider +from constructs import Construct, IConstruct + +from helper_constructs.sm_roles import SMRoles + + +class SagemakerStudioStack(Stack): + def __init__( + self, + scope: Construct, + construct_id: str, + *, + project_name: str, + deployment_name: str, + module_name: str, + vpc_id: str, + subnet_ids: List[str], + studio_domain_name: str, + studio_bucket_name: str, + data_science_users: List[str], + lead_data_science_users: List[str], + app_image_config_name: str, + image_name: str, + enable_custom_sagemaker_projects: bool, + **kwargs: Any, + ) -> None: + super().__init__(scope, construct_id, **kwargs) + + dep_mod = f"{project_name}-{deployment_name}-{module_name}" + + # used to tag AWS resources. Tag Value length can't exceed 256 characters + full_dep_mod = dep_mod[:256] if len(dep_mod) > 256 else dep_mod + + Tags.of(scope=cast(IConstruct, self)).add(key="Deployment", value=full_dep_mod) + self.vpc = ec2.Vpc.from_lookup(self, "VPC", vpc_id=vpc_id) + + self.subnets = [ec2.Subnet.from_subnet_id(self, f"SUBNET-{subnet_id}", subnet_id) for subnet_id in subnet_ids] + + domain_name = studio_domain_name + + s3_bucket_prefix = studio_bucket_name + + # create roles to be used for sagemaker user profiles and attached to sagemaker studio domain + self.sm_roles = SMRoles(self, "sm-roles", s3_bucket_prefix, kwargs["env"]) + + # setup security group to be used for sagemaker studio domain + sagemaker_sg = ec2.SecurityGroup( + self, + "SecurityGroup", + vpc=self.vpc, + description="Security Group for SageMaker Studio Notebook, Training Job and Hosting Endpoint", + ) + + sagemaker_sg.add_ingress_rule(sagemaker_sg, ec2.Port.all_traffic()) + + # create sagemaker studio domain + self.studio_domain = self.sagemaker_studio_domain( + domain_name, + self.sm_roles.sagemaker_studio_role, + vpc_id=self.vpc.vpc_id, + security_group_ids=[sagemaker_sg.security_group_id], + subnet_ids=[subnet.subnet_id for subnet in self.subnets], + app_image_config_name=app_image_config_name, + image_name=image_name, + ) + + if enable_custom_sagemaker_projects: + self.enable_sagemaker_projects( + [ + self.sm_roles.sagemaker_studio_role.role_arn, + self.sm_roles.data_scientist_role.role_arn, + self.sm_roles.lead_data_scientist_role.role_arn, + ], + ) + + [ + sagemaker.CfnUserProfile( + self, + f"ds-{user}", + domain_id=self.studio_domain.attr_domain_id, + user_profile_name=user, + user_settings=sagemaker.CfnUserProfile.UserSettingsProperty( + execution_role=self.sm_roles.data_scientist_role.role_arn, + ), + ) + for user in data_science_users + ] + + [ + sagemaker.CfnUserProfile( + self, + f"lead-ds-{user}", + domain_id=self.studio_domain.attr_domain_id, + user_profile_name=user, + user_settings=sagemaker.CfnUserProfile.UserSettingsProperty( + execution_role=self.sm_roles.lead_data_scientist_role.role_arn, + ), + ) + for user in lead_data_science_users + ] + + def enable_sagemaker_projects(self, roles: List[str]) -> None: + event_handler = PythonFunction( + self, + "sg-project-function", + runtime=lambda_.Runtime.PYTHON_3_8, + entry="functions/sm_studio/enable_sm_projects", + timeout=core.Duration.seconds(120), + ) + + event_handler.add_to_role_policy( + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=[ + "sagemaker:EnableSagemakerServicecatalogPortfolio", + "servicecatalog:ListAcceptedPortfolioShares", + "servicecatalog:AssociatePrincipalWithPortfolio", + "servicecatalog:AcceptPortfolioShare", + "iam:GetRole", + ], + resources=["*"], + ), + ) + + provider = Provider( + self, + "sg-project-lead-provider", + on_event_handler=event_handler, + ) + + core.CustomResource( + self, + "cs-sg-project", + service_token=provider.service_token, + removal_policy=core.RemovalPolicy.DESTROY, + resource_type="Custom::EnableSageMakerProjects", + properties={ + "iteration": 1, + "ExecutionRoles": roles, + }, + ) + + def sagemaker_studio_domain( + self, + domain_name: str, + sagemaker_studio_role: iam.Role, + security_group_ids: List[str], + subnet_ids: List[str], + vpc_id: str, + app_image_config_name: str, + image_name: str, + ) -> sagemaker.CfnDomain: + """ + Create the SageMaker Studio Domain + + :param domain_name: - name to assign to the SageMaker Studio Domain + :param s3_bucket: - S3 bucket used for sharing notebooks between users + :param sagemaker_studio_role: - IAM Execution Role for the domain + :param security_group_ids: - list of comma separated security group ids + :param subnet_ids: - list of comma separated subnet ids + :param vpc_id: - VPC Id for the domain + """ + custom_kernel_settings = {} + if app_image_config_name is not None and image_name is not None: + custom_kernel_settings[ + "kernel_gateway_app_settings" + ] = sagemaker.CfnDomain.KernelGatewayAppSettingsProperty( + custom_images=[ + sagemaker.CfnDomain.CustomImageProperty( + app_image_config_name=app_image_config_name, + image_name=image_name, + ), + ], + ) + + return sagemaker.CfnDomain( + self, + "sagemaker-domain", + auth_mode="IAM", + app_network_access_type="VpcOnly", + default_user_settings=sagemaker.CfnDomain.UserSettingsProperty( + execution_role=sagemaker_studio_role.role_arn, + security_groups=security_group_ids, + sharing_settings=sagemaker.CfnDomain.SharingSettingsProperty(), + **custom_kernel_settings, # type:ignore + ), + domain_name=domain_name, + subnet_ids=subnet_ids, + vpc_id=vpc_id, + ) diff --git a/modules/sagemaker/sagemaker-studio/tests/__init__.py b/modules/sagemaker/sagemaker-studio/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/sagemaker/sagemaker-studio/tests/test_app.py b/modules/sagemaker/sagemaker-studio/tests/test_app.py new file mode 100644 index 00000000..e07cbb52 --- /dev/null +++ b/modules/sagemaker/sagemaker-studio/tests/test_app.py @@ -0,0 +1,53 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import os +import sys + +import pytest + + +@pytest.fixture(scope="function") +def stack_defaults(): + os.environ["SEEDFARMER_PROJECT_NAME"] = "test-project" + os.environ["SEEDFARMER_DEPLOYMENT_NAME"] = "test-deployment" + os.environ["SEEDFARMER_MODULE_NAME"] = "test-module" + os.environ["CDK_DEFAULT_ACCOUNT"] = "111111111111" + os.environ["CDK_DEFAULT_REGION"] = "us-east-1" + os.environ["SEEDFARMER_PARAMETER_RETENTION_TYPE"] = "DESTROY" + os.environ["SEEDFARMER_PARAMETER_VPC_ID"] = "vpc-12345" + os.environ["SEEDFARMER_PARAMETER_PRIVATE_SUBNET_IDS"] = '["subnet-12345", "subnet-54321"]' + # Unload the app import so that subsequent tests don't reuse + if "app" in sys.modules: + del sys.modules["app"] + + +def test_app(stack_defaults): + import app # noqa: F401 + + +def test_retention_default(stack_defaults): + del os.environ["SEEDFARMER_PARAMETER_RETENTION_TYPE"] + + with pytest.raises(Exception): + import app # noqa: F401 + + assert os.environ["SEEDFARMER_PARAMETER_RETENTION_TYPE"] == "DESTROY" + + +def test_vpc_id(stack_defaults): + del os.environ["SEEDFARMER_PARAMETER_VPC_ID"] + + with pytest.raises(Exception): + import app # noqa: F401 + + assert os.environ["SEEDFARMER_PARAMETER_VPC_ID"] == "vpc-12345" + + +def test_private_subnet_ids(stack_defaults): + del os.environ["SEEDFARMER_PARAMETER_PRIVATE_SUBNET_IDS"] + + with pytest.raises(Exception): + import app # noqa: F401 + + assert os.environ["SEEDFARMER_PARAMETER_PRIVATE_SUBNET_IDS"] == ["subnet-12345", "subnet-54321"] diff --git a/modules/sagemaker/sagemaker-studio/tests/test_stack.py b/modules/sagemaker/sagemaker-studio/tests/test_stack.py new file mode 100644 index 00000000..fdbb186f --- /dev/null +++ b/modules/sagemaker/sagemaker-studio/tests/test_stack.py @@ -0,0 +1,64 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import os +import sys + +import aws_cdk as cdk +import pytest +from aws_cdk.assertions import Template + + +@pytest.fixture(scope="function") +def stack_defaults(): + os.environ["CDK_DEFAULT_ACCOUNT"] = "111111111111" + os.environ["CDK_DEFAULT_REGION"] = "us-east-1" + + # Unload the app import so that subsequent tests don't reuse + + if "stack" in sys.modules: + del sys.modules["stack"] + + +def test_synthesize_stack(stack_defaults): + import stack + + app = cdk.App() + project_name = "test-project" + dep_name = "test-deployment" + mod_name = "test-module" + studio_domain_name = "test-domain" + studio_bucket_name = "test-bucket" + data_science_users = ["ds-user-1"] + lead_data_science_users = ["lead-ds-user-1"] + app_image_config_name = None + image_name = None + enable_custom_sagemaker_projects = False + + stack = stack.SagemakerStudioStack( + app, + f"{project_name}-{dep_name}-{mod_name}", + project_name=project_name, + deployment_name=dep_name, + module_name=mod_name, + vpc_id="vpc-12345", + subnet_ids=["subnet-12345", "subnet-54321"], + studio_domain_name=studio_domain_name, + studio_bucket_name=studio_bucket_name, + data_science_users=data_science_users, + lead_data_science_users=lead_data_science_users, + env=cdk.Environment( + account=os.environ["CDK_DEFAULT_ACCOUNT"], + region=os.environ["CDK_DEFAULT_REGION"], + ), + app_image_config_name=app_image_config_name, + image_name=image_name, + enable_custom_sagemaker_projects=enable_custom_sagemaker_projects, + ) + + template = Template.from_stack(stack) + + template.resource_count_is("AWS::SageMaker::Domain", 1) + template.resource_count_is("AWS::SageMaker::UserProfile", 2) + template.resource_count_is("AWS::EC2::SecurityGroup", 1) + template.resource_count_is("AWS::IAM::Role", 3) diff --git a/modules/sagemaker/sagemaker-studio/update-domain-input.template.json b/modules/sagemaker/sagemaker-studio/update-domain-input.template.json new file mode 100644 index 00000000..74bad1df --- /dev/null +++ b/modules/sagemaker/sagemaker-studio/update-domain-input.template.json @@ -0,0 +1,13 @@ +{ + "DomainId": "$DOMAIN_ID", + "DefaultUserSettings": { + "JupyterServerAppSettings": { + "DefaultResourceSpec": { + "LifecycleConfigArn": "$LCC_ARN" + }, + "LifecycleConfigArns": [ + "$LCC_ARN" + ] + } + } +} From 6236b5c708126d5b727459f22e72975b55e033f7 Mon Sep 17 00:00:00 2001 From: Anton Kukushkin Date: Tue, 20 Feb 2024 11:21:05 +0000 Subject: [PATCH 2/7] bump CDK version Signed-off-by: Anton Kukushkin --- modules/sagemaker/sagemaker-studio/deployspec.yaml | 4 ++-- modules/sagemaker/sagemaker-studio/requirements.txt | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/modules/sagemaker/sagemaker-studio/deployspec.yaml b/modules/sagemaker/sagemaker-studio/deployspec.yaml index a8ba6c0a..534249bf 100644 --- a/modules/sagemaker/sagemaker-studio/deployspec.yaml +++ b/modules/sagemaker/sagemaker-studio/deployspec.yaml @@ -3,7 +3,7 @@ deploy: phases: install: commands: - - npm install -g aws-cdk@2.89.0 + - npm install -g aws-cdk@2.128.0 - pip install -r requirements.txt - apt-get install gettext-base build: @@ -28,7 +28,7 @@ destroy: phases: install: commands: - - npm install -g aws-cdk@2.89.0 + - npm install -g aws-cdk@2.128.0 - pip install -r requirements.txt build: commands: diff --git a/modules/sagemaker/sagemaker-studio/requirements.txt b/modules/sagemaker/sagemaker-studio/requirements.txt index 8f3aefaf..b3abd7e4 100644 --- a/modules/sagemaker/sagemaker-studio/requirements.txt +++ b/modules/sagemaker/sagemaker-studio/requirements.txt @@ -1,4 +1,3 @@ -aws-cdk-lib==2.89 -aws_cdk.aws_lambda_python_alpha==2.89.0a0 -constructs>=10.0.0,<11.0.0 -cdk-nag==2.27.87 +aws-cdk-lib==2.128.0 +aws_cdk.aws_lambda_python_alpha==2.128.0a0 +cdk-nag==2.28.40 From 1a35bd2a15f1318e7d75c2ef3268e5fa7486db3c Mon Sep 17 00:00:00 2001 From: Anton Kukushkin Date: Tue, 20 Feb 2024 11:29:29 +0000 Subject: [PATCH 3/7] update manifest local path Signed-off-by: Anton Kukushkin --- manifests/sagemaker-studio-modules.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifests/sagemaker-studio-modules.yaml b/manifests/sagemaker-studio-modules.yaml index 321fdf21..4404c2c4 100644 --- a/manifests/sagemaker-studio-modules.yaml +++ b/manifests/sagemaker-studio-modules.yaml @@ -1,5 +1,5 @@ name: studio -path: git::https://github.com/awslabs/idf-modules.git//modules/ml/sagemaker-studio?ref=release/1.3.0&depth=1 +path: modules/sagemaker/sagemaker-studio targetAccount: primary parameters: - name: studio_domain_name From fc1566bf9b942ad9ee346c1bf46f7384473b9eea Mon Sep 17 00:00:00 2001 From: Anton Kukushkin Date: Tue, 20 Feb 2024 11:59:17 +0000 Subject: [PATCH 4/7] update readme Signed-off-by: Anton Kukushkin --- README.md | 2 ++ modules/sagemaker/sagemaker-studio/README.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d7a05606..c0c9d3d4 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ All modules in this repository adhere to the module strutucture defined in the t | Type | Description | |-----------------------------------------------------------------------------|-------------------------------------------------| | [SageMaker Endpoint Module](modules/sagemaker/sagemaker-endpoint/README.md) | Creates SageMaker real-time inference endpoint. | +| [SageMaker Studio Module](modules/sagemaker/sagemaker-studio/README.md) | Creates SageMaker Studio Domain. | + ### Industry Data Framework (IDF) Modules diff --git a/modules/sagemaker/sagemaker-studio/README.md b/modules/sagemaker/sagemaker-studio/README.md index 13eb025a..769ba23e 100644 --- a/modules/sagemaker/sagemaker-studio/README.md +++ b/modules/sagemaker/sagemaker-studio/README.md @@ -1,4 +1,4 @@ -# SageMaker studio Infrastructure +# SageMaker Studio Infrastructure This module contains the resources that are required to deploy the SageMaker Studio infrastructure. It defines the setup for Amazon SageMaker Studio Domain and creates SageMaker Studio User Profiles for Data Scientists and Lead Data Scientists. From 59ad094d85251de94d363e90642593cd5cf5e0df Mon Sep 17 00:00:00 2001 From: Anton Kukushkin Date: Tue, 20 Feb 2024 12:02:42 +0000 Subject: [PATCH 5/7] add architecture diagram Signed-off-by: Anton Kukushkin --- modules/sagemaker/sagemaker-studio/README.md | 6 ++++-- .../sagemaker-studio-module-architecture.png | Bin 0 -> 21078 bytes .../sagemaker-studio-module-architecture.xml | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 modules/sagemaker/sagemaker-studio/docs/_static/sagemaker-studio-module-architecture.png create mode 100644 modules/sagemaker/sagemaker-studio/docs/_static/sagemaker-studio-module-architecture.xml diff --git a/modules/sagemaker/sagemaker-studio/README.md b/modules/sagemaker/sagemaker-studio/README.md index 769ba23e..4c17fe74 100644 --- a/modules/sagemaker/sagemaker-studio/README.md +++ b/modules/sagemaker/sagemaker-studio/README.md @@ -15,9 +15,11 @@ This module contains the resources that are required to deploy the SageMaker Stu - [Module Structure](#module-structure) - [Troubleshooting](#troubleshooting) -### SageMaker Studio Stack +### Architecture -This stack handles the deployment of the following resources: +![SageMaker Studio Module Architecture](docs/_static/sagemaker-studio-module-architecture.png "SageMaker Studio Module Architecture") + +This module handles the deployment of the following resources: 1. SageMaker Studio Domain requires, along with 2. IAM roles which would be linked to SM Studio user profiles. User Profile creating process is managed by manifests files in `manifests/shared-infra/mlops-modules.yaml`. You can simply add new entries in the list to create a new user. The user will be linked to a role depending on which group you add them to (`data_science_users` or `lead_data_science_users`). diff --git a/modules/sagemaker/sagemaker-studio/docs/_static/sagemaker-studio-module-architecture.png b/modules/sagemaker/sagemaker-studio/docs/_static/sagemaker-studio-module-architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..bf66cf099f61467c14e90e15554e9745110e7ccb GIT binary patch literal 21078 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 literal 0 HcmV?d00001 diff --git a/modules/sagemaker/sagemaker-studio/docs/_static/sagemaker-studio-module-architecture.xml b/modules/sagemaker/sagemaker-studio/docs/_static/sagemaker-studio-module-architecture.xml new file mode 100644 index 00000000..89088c83 --- /dev/null +++ b/modules/sagemaker/sagemaker-studio/docs/_static/sagemaker-studio-module-architecture.xml @@ -0,0 +1 @@ +7VzbktsoEP0aP+ISAt0efcnsTlWylcpsspunLYSQrB1ZuBAe2/n6bXTxSLbmVmPHdtbJVCIaRDfQ9Dm08QzIZL7+TbHF7JOMRDawrWg9INOBbQcuhX+NYFMJXKcWJCqNKhF+FNylP0QttGrpMo1E0Wmopcx0uugKucxzwXVHxpSSq26zWGZdrQuWiD3BHWfZvvSvNNKzSurb3qP8d5Ems0YzdoOqZs6axvVIihmL5KolIh8GZKKk1NXTfD0RmZm7Zl7q93Kx1qbmNvrGsmVjFtn28EUUcqm4mIqCq3ShpYKXVC2sWv8zIKPGCqFSlqU/mE5ljh6EKuD/qtVD3YTVU6Z6Oq513ok5y3XKp0yzicw1S3OhXtN79bZWaZ58TLVQLKvWTotcd0a9UHIhlK69Zqa1We/RwL6BH2guM5lshoXgS5XqzZDN2Q+ZDyPxANWxXOZRaQEUopQlis3RQ1ost5aBnNm24wSOiziJYkS5zVFI/QCKlhe7Pgt5UC3xTWXz7ZfbvWl9k1XgxWmSozQvFuCnZi5vuJwvZA4jL6DgU+ZboRMjx6U2ogx7KHB8B4kwjkLXiWko+GGnptgUWszR3GxZWA+QWNQhgRN4iNo+QTR2HeRbgYWYcL04iIgIaNCeFHjo94Smtsc3m6ra3ftdf7vrVNcv3uLw+EIcHl8d/n/n8DL814CVbWUsBLwsxzWw3QxcYhylYLibmMdGFDaCb58njQx6DHfblcvferv0f/A0nmZ/bhb1Ij4s6mltNtt3KpLg+4YEbJZsPk498jdVX5C99d7t7iz0poHFhUzNKsKzM4YfcPWJNXCgZmJKQ9vZEeyWva4A75dMH13BbtnrCvBu93hHP941sCXYK3W6t3b0Wy0D4YeM5VKDR4nJloRYIIRtGKXg7BNwUHCDaQ6uD+KZnpsVx/C4mkFIuFuwcn+tgD+BLAafqmkQtptyPfGmV6AR5UrO14khXEO2KmxrmCi5XJQ6b3kZgHqq/zErDx1oJe9FY9TAJjb1fUyNqjTLdoyFiAa+zrJRBnsZxFoaLawuZSI2jlbACEx8K0tTYtVWt1SMRmNv7BvnZsVMRPVQKjc0KsT6ybhfi1QUf12YGRVdiGBFIbRx7m/t2EtazvubkHOh1QYa11pstw78dUTx6+LqkeDZTi2btcmduwMYybbrxxDxSnR6ZtNdAGY9a/0VyS4AyY4+KTR2cBDgGMWEuzApFCYltCxkYeK4FgsDO6ZPTcrWhtVqNVyRoVRmiNBdYEZqBmHbCCICKjbg1muUg0KiDcIdcoSdGS+jKYLYueR6qYSxI6RCMO4jiwoHUQEL7rsB+GLkuHEYUC/0wqMO8NkdWjKFtyi3LcsyyiGu3pSa+QziB6itKMpr9X7N73O5yj+bY/VPiQaWw7lDMEeRTTGiASfg+JFZBuJ5Hneo7cbHoHDG/IrFvYvRjcoBw3t3MGOf2H0Zoe/0MkolPEzlHIx7N+UroO951ffLxA+/nfj1kr8+AthLAveJYKdZSc16NOwK+2TevhDvN2vY3L6wT9ZHXXffxj1v4523nyaOO+QJ/t6Y9dgjlFBHR2M7GLXqpqmCjipAz6UyfKbD6+AdjN0pLF4PE4zLP7s0reGAH41nf5ZFWncfSq3l/EWSyMEqs73axPclkstKUIFCnK6NHU+w3ia4V5x3DMVe9vvo/qfjnIQ6Hc6J8T7p9Hs4p39synkpaZJnrb9SzivlvFLOE1PO11GZKyc9MSftZYiJAHUpR3zGgIlkr+GJ/rM8UUSJaBDd0BCZyJxlHx6lY2VmcksyWuTgX6H1pmYHbKmloUnbHj5KQy7KdpWRRtFLQN7ej/2DIVVDzVQingUcekBqoEQGHvMgdno4EtD7Fw30/nkC/Thj+f0fgIm30zcsBTnsYPaBIsIxt1xXIOK7hh4IC/nEosjlIqaAghar85vvGsaTYHqoYbjYCwXscMRjIDiUWRgxz4mQj0VEAN5th9g/E39dBzuea1vIoyHY41kCQnoQoTAIY8cNXR7S4+LvlVf28Mor9h8wH/W1MHH8nfkmxmuseIlDtIFxn0M0h/7lPBuVPb4iF1EOd8z4fVLyi77kRn+6okVAepMyp0oceN3PqrC9nzcgPXmDo39URS6aThwYgc8mb/BCaGU+d6woihBjgY+ozwVimHHksMCJ4fRMhXfgmTmDtMFFH6qreHw9NP8ih2bvWIdmWDe1+buWl4XvpjB0muJ03a6cbtqlzzAMQAczd6XwFCdw+toT+CEx9ueewL2LhszmxvOZQeaZHl1/zgn8wB9/XE/g1xP49QR+4hP47egTvPRFAt6/9xyuyk5eZiX0WVbyrjsK9T3SnSsIEw8TfLN3BaFufHa3D8pLL0J9eBDV3Rf89I0EWdK0UyULcPPtqnO7ZUAvmvocmC2cTbbgV8Si6y2DXzsh0obHa1rkrNMiza55DQXBjwG6j4N08x5935tp4/v+91i2lAKfEp1JN5fv7oMztXrQ2baOfgnwUr4d/Lz5V4C+AvQVoE/9icX/FCENr9j+Moeyaes3YpAP/wE= \ No newline at end of file From 62e8e47509a4f8742d448d6547d7d9b9e3bce942 Mon Sep 17 00:00:00 2001 From: Anton Kukushkin Date: Tue, 20 Feb 2024 14:13:15 +0000 Subject: [PATCH 6/7] add CHANGELOG.MD Signed-off-by: Anton Kukushkin --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..9b714ea4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Change Log + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +======= + +## UNRELEASED + +### **Added** + +- added `sagemaker-studio` module +- added `sagemaker-endpoint` module + +### **Changed** + +### **Removed** + +======= \ No newline at end of file From 9be271cc7a4b62c6c133fef73b91263c455a2f01 Mon Sep 17 00:00:00 2001 From: Anton Kukushkin Date: Tue, 20 Feb 2024 17:17:56 +0000 Subject: [PATCH 7/7] undocumented params Signed-off-by: Anton Kukushkin --- modules/sagemaker/sagemaker-studio/README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/modules/sagemaker/sagemaker-studio/README.md b/modules/sagemaker/sagemaker-studio/README.md index 4c17fe74..6ea4494a 100644 --- a/modules/sagemaker/sagemaker-studio/README.md +++ b/modules/sagemaker/sagemaker-studio/README.md @@ -37,11 +37,11 @@ This module handles the deployment of the following resources: ## Inputs and outputs: ### Required inputs: - - `VPC_ID` - - `subnet_ids` + - `vpc_id` - the VPC id that the SageMaker Studio Domain will be created in + - `subnet_ids` - the subnets that the SageMaker Studio Domai will be created in ### Optional Inputs: - - `studio_domain_name` - - `studio_bucket_name` + - `studio_domain_name` - name of the SageMaker Studio Domain + - `studio_bucket_name` - name of the bucket used by studio - `app_image_config_name` - custom kernel app config name - `image_name` - custom kernel image name - `data_science_users` - a list of data science user names to create @@ -54,9 +54,9 @@ This module handles the deployment of the following resources: - `StudioDomainId` - the Id of the domain created by Sagemaker Studio - `StudioBucketName` - the Bucket (or prefix) given access to Sagemaker Studio - `StudioDomainEFSId` - the EFS created by Sagemaker Studio - - `DataScientistRoleArn` - - `LeadDataScientistRoleArn` - - `SageMakerExecutionRoleArn` + - `DataScientistRoleArn` - ARN of the Data Scientist IAM role + - `LeadDataScientistRoleArn` - ARN of the Lead Data Scientist IAM role + - `SageMakerExecutionRoleArn` - ARN of the SageMaker execution IAM role ### Example Output: ```yaml