diff --git a/.changelog/21041.txt b/.changelog/21041.txt new file mode 100644 index 00000000000..d804086541b --- /dev/null +++ b/.changelog/21041.txt @@ -0,0 +1,11 @@ +```release-note:new-resource +aws_sagemaker_studio_lifecycle_config +``` + +```release-note:enhancement +resource/aws_sagemaker_domain: Add `default_user_settings.jupyter_server_app_settings.lifecycle_config_arns` and `default_user_settings.kernel_gateway_app_settings.lifecycle_config_arns` arguments +``` + +```release-note:enhancement +resource/aws_user_profile: Add `user_settings.jupyter_server_app_settings.lifecycle_config_arns` and `user_settings.kernel_gateway_app_settings.lifecycle_config_arns` arguments +``` \ No newline at end of file diff --git a/aws/internal/service/sagemaker/finder/finder.go b/aws/internal/service/sagemaker/finder/finder.go index 90bcb4362d2..cccf1e6be2f 100644 --- a/aws/internal/service/sagemaker/finder/finder.go +++ b/aws/internal/service/sagemaker/finder/finder.go @@ -349,3 +349,28 @@ func FlowDefinitionByName(conn *sagemaker.SageMaker, name string) (*sagemaker.De return output, nil } + +func StudioLifecycleConfigByName(conn *sagemaker.SageMaker, name string) (*sagemaker.DescribeStudioLifecycleConfigOutput, error) { + input := &sagemaker.DescribeStudioLifecycleConfigInput{ + StudioLifecycleConfigName: aws.String(name), + } + + output, err := conn.DescribeStudioLifecycleConfig(input) + + if tfawserr.ErrCodeEquals(err, sagemaker.ErrCodeResourceNotFound) { + return nil, &resource.NotFoundError{ + LastError: err, + LastRequest: input, + } + } + + if err != nil { + return nil, err + } + + if output == nil { + return nil, tfresource.NewEmptyResultError(input) + } + + return output, nil +} diff --git a/aws/provider.go b/aws/provider.go index 7c678362f6e..b3c8339bbca 100644 --- a/aws/provider.go +++ b/aws/provider.go @@ -1055,6 +1055,7 @@ func Provider() *schema.Provider { "aws_sagemaker_model_package_group": resourceAwsSagemakerModelPackageGroup(), "aws_sagemaker_notebook_instance_lifecycle_configuration": resourceAwsSagemakerNotebookInstanceLifeCycleConfiguration(), "aws_sagemaker_notebook_instance": resourceAwsSagemakerNotebookInstance(), + "aws_sagemaker_studio_lifecycle_config": resourceAwsSagemakerStudioLifecycleConfig(), "aws_sagemaker_user_profile": resourceAwsSagemakerUserProfile(), "aws_sagemaker_workforce": resourceAwsSagemakerWorkforce(), "aws_sagemaker_workteam": resourceAwsSagemakerWorkteam(), diff --git a/aws/resource_aws_sagemaker_domain.go b/aws/resource_aws_sagemaker_domain.go index 175d35b15bb..6b39ca797c6 100644 --- a/aws/resource_aws_sagemaker_domain.go +++ b/aws/resource_aws_sagemaker_domain.go @@ -168,6 +168,14 @@ func resourceAwsSagemakerDomain() *schema.Resource { }, }, }, + "lifecycle_config_arns": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validateArn, + }, + }, }, }, }, @@ -197,6 +205,14 @@ func resourceAwsSagemakerDomain() *schema.Resource { }, }, }, + "lifecycle_config_arns": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validateArn, + }, + }, "custom_image": { Type: schema.TypeList, Optional: true, @@ -478,6 +494,10 @@ func expandSagemakerDomainJupyterServerAppSettings(l []interface{}) *sagemaker.J config.DefaultResourceSpec = expandSagemakerDomainDefaultResourceSpec(v) } + if v, ok := m["lifecycle_config_arns"].(*schema.Set); ok && v.Len() > 0 { + config.LifecycleConfigArns = expandStringSet(v) + } + return config } @@ -494,6 +514,10 @@ func expandSagemakerDomainKernelGatewayAppSettings(l []interface{}) *sagemaker.K config.DefaultResourceSpec = expandSagemakerDomainDefaultResourceSpec(v) } + if v, ok := m["lifecycle_config_arns"].(*schema.Set); ok && v.Len() > 0 { + config.LifecycleConfigArns = expandStringSet(v) + } + if v, ok := m["custom_image"].([]interface{}); ok && len(v) > 0 { config.CustomImages = expandSagemakerDomainCustomImages(v) } @@ -657,6 +681,10 @@ func flattenSagemakerDomainJupyterServerAppSettings(config *sagemaker.JupyterSer m["default_resource_spec"] = flattenSagemakerDomainDefaultResourceSpec(config.DefaultResourceSpec) } + if config.LifecycleConfigArns != nil { + m["lifecycle_config_arns"] = flattenStringSet(config.LifecycleConfigArns) + } + return []map[string]interface{}{m} } @@ -671,6 +699,10 @@ func flattenSagemakerDomainKernelGatewayAppSettings(config *sagemaker.KernelGate m["default_resource_spec"] = flattenSagemakerDomainDefaultResourceSpec(config.DefaultResourceSpec) } + if config.LifecycleConfigArns != nil { + m["lifecycle_config_arns"] = flattenStringSet(config.LifecycleConfigArns) + } + if config.CustomImages != nil { m["custom_image"] = flattenSagemakerDomainCustomImages(config.CustomImages) } diff --git a/aws/resource_aws_sagemaker_domain_test.go b/aws/resource_aws_sagemaker_domain_test.go index cff02a0a538..398d721b72a 100644 --- a/aws/resource_aws_sagemaker_domain_test.go +++ b/aws/resource_aws_sagemaker_domain_test.go @@ -342,6 +342,38 @@ func testAccAWSSagemakerDomain_kernelGatewayAppSettings(t *testing.T) { }) } +func testAccAWSSagemakerDomain_kernelGatewayAppSettings_lifecycleConfig(t *testing.T) { + var domain sagemaker.DescribeDomainOutput + rName := acctest.RandomWithPrefix("tf-acc-test") + resourceName := "aws_sagemaker_domain.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ErrorCheck: testAccErrorCheck(t, sagemaker.EndpointsID), + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSSagemakerDomainDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSSagemakerDomainConfigKernelGatewayAppSettingsLifecycleConfig(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSSagemakerDomainExists(resourceName, &domain), + resource.TestCheckResourceAttr(resourceName, "default_user_settings.#", "1"), + resource.TestCheckResourceAttr(resourceName, "default_user_settings.0.kernel_gateway_app_settings.#", "1"), + resource.TestCheckResourceAttr(resourceName, "default_user_settings.0.kernel_gateway_app_settings.0.lifecycle_config_arns.#", "1"), + resource.TestCheckResourceAttr(resourceName, "default_user_settings.0.kernel_gateway_app_settings.0.default_resource_spec.#", "1"), + resource.TestCheckResourceAttr(resourceName, "default_user_settings.0.kernel_gateway_app_settings.0.default_resource_spec.0.instance_type", "ml.t3.micro"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"retention_policy"}, + }, + }, + }) +} + func testAccAWSSagemakerDomain_kernelGatewayAppSettings_customImage(t *testing.T) { if os.Getenv("SAGEMAKER_IMAGE_VERSION_BASE_IMAGE") == "" { @@ -820,6 +852,39 @@ resource "aws_sagemaker_domain" "test" { `, rName) } +func testAccAWSSagemakerDomainConfigKernelGatewayAppSettingsLifecycleConfig(rName string) string { + return testAccAWSSagemakerDomainConfigBase(rName) + fmt.Sprintf(` +resource "aws_sagemaker_studio_lifecycle_config" "test" { + studio_lifecycle_config_name = %[1]q + studio_lifecycle_config_app_type = "JupyterServer" + studio_lifecycle_config_content = base64encode("echo Hello") +} + +resource "aws_sagemaker_domain" "test" { + domain_name = %[1]q + auth_mode = "IAM" + vpc_id = aws_vpc.test.id + subnet_ids = [aws_subnet.test.id] + + default_user_settings { + execution_role = aws_iam_role.test.arn + + kernel_gateway_app_settings { + default_resource_spec { + instance_type = "ml.t3.micro" + } + + lifecycle_config_arns = [aws_sagemaker_studio_lifecycle_config.test.arn] + } + } + + retention_policy { + home_efs_file_system = "Delete" + } +} +`, rName) +} + func testAccAWSSagemakerDomainConfigKernelGatewayAppSettingsCustomImage(rName, baseImage string) string { return testAccAWSSagemakerDomainConfigBase(rName) + fmt.Sprintf(` resource "aws_sagemaker_image" "test" { diff --git a/aws/resource_aws_sagemaker_studio_lifecycle_config.go b/aws/resource_aws_sagemaker_studio_lifecycle_config.go new file mode 100644 index 00000000000..dcd8ae470ec --- /dev/null +++ b/aws/resource_aws_sagemaker_studio_lifecycle_config.go @@ -0,0 +1,164 @@ +package aws + +import ( + "fmt" + "log" + "regexp" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/sagemaker" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/keyvaluetags" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/sagemaker/finder" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/tfresource" +) + +func resourceAwsSagemakerStudioLifecycleConfig() *schema.Resource { + return &schema.Resource{ + Create: resourceAwsSagemakerStudioLifecycleConfigCreate, + Read: resourceAwsSagemakerStudioLifecycleConfigRead, + Update: resourceAwsSagemakerStudioLifecycleConfigUpdate, + Delete: resourceAwsSagemakerStudioLifecycleConfigDelete, + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Schema: map[string]*schema.Schema{ + "arn": { + Type: schema.TypeString, + Computed: true, + }, + "studio_lifecycle_config_app_type": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringInSlice(sagemaker.StudioLifecycleConfigAppType_Values(), false), + }, + "studio_lifecycle_config_content": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringLenBetween(1, 16384), + }, + "studio_lifecycle_config_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.All( + validation.StringLenBetween(1, 63), + validation.StringMatch(regexp.MustCompile(`^[a-zA-Z0-9](-*[a-zA-Z0-9])*$`), "Valid characters are a-z, A-Z, 0-9, and - (hyphen)."), + ), + }, + "tags": tagsSchema(), + "tags_all": tagsSchemaComputed(), + }, + + CustomizeDiff: SetTagsDiff, + } +} + +func resourceAwsSagemakerStudioLifecycleConfigCreate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).sagemakerconn + defaultTagsConfig := meta.(*AWSClient).DefaultTagsConfig + tags := defaultTagsConfig.MergeTags(keyvaluetags.New(d.Get("tags").(map[string]interface{}))) + + name := d.Get("studio_lifecycle_config_name").(string) + input := &sagemaker.CreateStudioLifecycleConfigInput{ + StudioLifecycleConfigName: aws.String(name), + StudioLifecycleConfigAppType: aws.String(d.Get("studio_lifecycle_config_app_type").(string)), + StudioLifecycleConfigContent: aws.String(d.Get("studio_lifecycle_config_content").(string)), + } + + if len(tags) > 0 { + input.Tags = tags.IgnoreAws().SagemakerTags() + } + + log.Printf("[DEBUG] Creating SageMaker Studio Lifecycle Config : %s", input) + _, err := conn.CreateStudioLifecycleConfig(input) + + if err != nil { + return fmt.Errorf("error creating SageMaker Studio Lifecycle Config (%s): %w", name, err) + } + + d.SetId(name) + + return resourceAwsSagemakerStudioLifecycleConfigRead(d, meta) +} + +func resourceAwsSagemakerStudioLifecycleConfigRead(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).sagemakerconn + defaultTagsConfig := meta.(*AWSClient).DefaultTagsConfig + ignoreTagsConfig := meta.(*AWSClient).IgnoreTagsConfig + + image, err := finder.StudioLifecycleConfigByName(conn, d.Id()) + + if !d.IsNewResource() && tfresource.NotFound(err) { + log.Printf("[WARN] SageMaker Studio Lifecycle Config (%s) not found, removing from state", d.Id()) + d.SetId("") + return nil + } + + if err != nil { + return fmt.Errorf("error reading SageMaker Studio Lifecycle Config (%s): %w", d.Id(), err) + } + + arn := aws.StringValue(image.StudioLifecycleConfigArn) + d.Set("studio_lifecycle_config_name", image.StudioLifecycleConfigName) + d.Set("studio_lifecycle_config_app_type", image.StudioLifecycleConfigAppType) + d.Set("studio_lifecycle_config_content", image.StudioLifecycleConfigContent) + d.Set("arn", arn) + + tags, err := keyvaluetags.SagemakerListTags(conn, arn) + + if err != nil { + return fmt.Errorf("error listing tags for SageMaker Studio Lifecycle Config (%s): %w", d.Id(), err) + } + + tags = tags.IgnoreAws().IgnoreConfig(ignoreTagsConfig) + + //lintignore:AWSR002 + if err := d.Set("tags", tags.RemoveDefaultConfig(defaultTagsConfig).Map()); err != nil { + return fmt.Errorf("error setting tags: %w", err) + } + + if err := d.Set("tags_all", tags.Map()); err != nil { + return fmt.Errorf("error setting tags_all: %w", err) + } + + return nil +} + +func resourceAwsSagemakerStudioLifecycleConfigUpdate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).sagemakerconn + + if d.HasChange("tags_all") { + o, n := d.GetChange("tags_all") + + if err := keyvaluetags.SagemakerUpdateTags(conn, d.Get("arn").(string), o, n); err != nil { + return fmt.Errorf("error updating Studio Lifecycle Config (%s) tags: %w", d.Id(), err) + } + } + + return resourceAwsSagemakerStudioLifecycleConfigRead(d, meta) +} + +func resourceAwsSagemakerStudioLifecycleConfigDelete(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).sagemakerconn + + input := &sagemaker.DeleteStudioLifecycleConfigInput{ + StudioLifecycleConfigName: aws.String(d.Id()), + } + + log.Printf("[DEBUG] Deleting SageMaker Studio Lifecycle Config: (%s)", d.Id()) + if _, err := conn.DeleteStudioLifecycleConfig(input); err != nil { + if tfawserr.ErrMessageContains(err, sagemaker.ErrCodeResourceNotFound, "does not exist") { + return nil + } + + return fmt.Errorf("error deleting SageMaker Studio Lifecycle Config (%s): %w", d.Id(), err) + } + + return nil +} diff --git a/aws/resource_aws_sagemaker_studio_lifecycle_config_test.go b/aws/resource_aws_sagemaker_studio_lifecycle_config_test.go new file mode 100644 index 00000000000..bfeae658049 --- /dev/null +++ b/aws/resource_aws_sagemaker_studio_lifecycle_config_test.go @@ -0,0 +1,251 @@ +package aws + +import ( + "fmt" + "log" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/sagemaker" + "github.com/hashicorp/go-multierror" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/sagemaker/finder" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/tfresource" +) + +func init() { + resource.AddTestSweepers("aws_sagemaker_studio_lifecycle_config", &resource.Sweeper{ + Name: "aws_sagemaker_studio_lifecycle_config", + F: testSweepSagemakerStudioLifecycleConfigs, + Dependencies: []string{ + "aws_sagemaker_domain", + }, + }) +} + +func testSweepSagemakerStudioLifecycleConfigs(region string) error { + client, err := sharedClientForRegion(region) + if err != nil { + return fmt.Errorf("error getting client: %w", err) + } + conn := client.(*AWSClient).sagemakerconn + var sweeperErrs *multierror.Error + + err = conn.ListStudioLifecycleConfigsPages(&sagemaker.ListStudioLifecycleConfigsInput{}, func(page *sagemaker.ListStudioLifecycleConfigsOutput, lastPage bool) bool { + for _, config := range page.StudioLifecycleConfigs { + + r := resourceAwsSagemakerStudioLifecycleConfig() + d := r.Data(nil) + d.SetId(aws.StringValue(config.StudioLifecycleConfigName)) + err := r.Delete(d, client) + if err != nil { + log.Printf("[ERROR] %s", err) + sweeperErrs = multierror.Append(sweeperErrs, err) + continue + } + } + + return !lastPage + }) + + if testSweepSkipSweepError(err) { + log.Printf("[WARN] Skipping SageMaker Studio Lifecycle Config sweep for %s: %s", region, err) + return sweeperErrs.ErrorOrNil() + } + + if err != nil { + sweeperErrs = multierror.Append(sweeperErrs, fmt.Errorf("error retrieving Sagemaker Studio Lifecycle Configs: %w", err)) + } + + return sweeperErrs.ErrorOrNil() +} + +func TestAccAWSSagemakerStudioLifecycleConfig_basic(t *testing.T) { + var config sagemaker.DescribeStudioLifecycleConfigOutput + rName := acctest.RandomWithPrefix("tf-acc-test") + resourceName := "aws_sagemaker_studio_lifecycle_config.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ErrorCheck: testAccErrorCheck(t, sagemaker.EndpointsID), + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSSagemakerStudioLifecycleConfigDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSSagemakerStudioLifecycleConfigBasicConfig(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSSagemakerStudioLifecycleConfigExists(resourceName, &config), + resource.TestCheckResourceAttr(resourceName, "studio_lifecycle_config_name", rName), + testAccCheckResourceAttrRegionalARN(resourceName, "arn", "sagemaker", fmt.Sprintf("studio-lifecycle-config/%s", rName)), + resource.TestCheckResourceAttr(resourceName, "studio_lifecycle_config_app_type", "JupyterServer"), + resource.TestCheckResourceAttrSet(resourceName, "studio_lifecycle_config_content"), + resource.TestCheckResourceAttr(resourceName, "tags.%", "0"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccAWSSagemakerStudioLifecycleConfig_tags(t *testing.T) { + var config sagemaker.DescribeStudioLifecycleConfigOutput + rName := acctest.RandomWithPrefix("tf-acc-test") + resourceName := "aws_sagemaker_studio_lifecycle_config.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ErrorCheck: testAccErrorCheck(t, sagemaker.EndpointsID), + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSSagemakerStudioLifecycleConfigDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSSagemakerStudioLifecycleConfigTags1(rName, "key1", "value1"), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSSagemakerStudioLifecycleConfigExists(resourceName, &config), + resource.TestCheckResourceAttr(resourceName, "tags.%", "1"), + resource.TestCheckResourceAttr(resourceName, "tags.key1", "value1"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccAWSSagemakerStudioLifecycleConfigTags2(rName, "key1", "value1updated", "key2", "value2"), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSSagemakerStudioLifecycleConfigExists(resourceName, &config), + resource.TestCheckResourceAttr(resourceName, "tags.%", "2"), + resource.TestCheckResourceAttr(resourceName, "tags.key1", "value1updated"), + resource.TestCheckResourceAttr(resourceName, "tags.key2", "value2"), + ), + }, + { + Config: testAccAWSSagemakerStudioLifecycleConfigTags1(rName, "key2", "value2"), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSSagemakerStudioLifecycleConfigExists(resourceName, &config), + resource.TestCheckResourceAttr(resourceName, "tags.%", "1"), + resource.TestCheckResourceAttr(resourceName, "tags.key2", "value2"), + ), + }, + }, + }) +} + +func TestAccAWSSagemakerStudioLifecycleConfig_disappears(t *testing.T) { + var config sagemaker.DescribeStudioLifecycleConfigOutput + rName := acctest.RandomWithPrefix("tf-acc-test") + resourceName := "aws_sagemaker_studio_lifecycle_config.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ErrorCheck: testAccErrorCheck(t, sagemaker.EndpointsID), + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSSagemakerStudioLifecycleConfigDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSSagemakerStudioLifecycleConfigBasicConfig(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSSagemakerStudioLifecycleConfigExists(resourceName, &config), + testAccCheckResourceDisappears(testAccProvider, resourceAwsSagemakerStudioLifecycleConfig(), resourceName), + testAccCheckResourceDisappears(testAccProvider, resourceAwsSagemakerStudioLifecycleConfig(), resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func testAccCheckAWSSagemakerStudioLifecycleConfigDestroy(s *terraform.State) error { + conn := testAccProvider.Meta().(*AWSClient).sagemakerconn + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_sagemaker_studio_lifecycle_config" { + continue + } + + _, err := finder.StudioLifecycleConfigByName(conn, rs.Primary.ID) + + if tfresource.NotFound(err) { + continue + } + + if err != nil { + return err + } + + return fmt.Errorf("SageMaker Studio Lifecycle Config %s still exists", rs.Primary.ID) + } + + return nil +} + +func testAccCheckAWSSagemakerStudioLifecycleConfigExists(n string, config *sagemaker.DescribeStudioLifecycleConfigOutput) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No SageMaker Studio Lifecycle Config ID is set") + } + + conn := testAccProvider.Meta().(*AWSClient).sagemakerconn + + output, err := finder.StudioLifecycleConfigByName(conn, rs.Primary.ID) + + if err != nil { + return err + } + + *config = *output + + return nil + } +} + +func testAccAWSSagemakerStudioLifecycleConfigBasicConfig(rName string) string { + return fmt.Sprintf(` +resource "aws_sagemaker_studio_lifecycle_config" "test" { + studio_lifecycle_config_name = %[1]q + studio_lifecycle_config_app_type = "JupyterServer" + studio_lifecycle_config_content = base64encode("echo Hello") +} +`, rName) +} + +func testAccAWSSagemakerStudioLifecycleConfigTags1(rName, tagKey1, tagValue1 string) string { + return fmt.Sprintf(` +resource "aws_sagemaker_studio_lifecycle_config" "test" { + studio_lifecycle_config_name = %[1]q + studio_lifecycle_config_app_type = "JupyterServer" + studio_lifecycle_config_content = base64encode("echo Hello") + + tags = { + %[2]q = %[3]q + } +} +`, rName, tagKey1, tagValue1) +} + +func testAccAWSSagemakerStudioLifecycleConfigTags2(rName, tagKey1, tagValue1, tagKey2, tagValue2 string) string { + return fmt.Sprintf(` +resource "aws_sagemaker_studio_lifecycle_config" "test" { + studio_lifecycle_config_name = %[1]q + studio_lifecycle_config_app_type = "JupyterServer" + studio_lifecycle_config_content = base64encode("echo Hello") + + tags = { + %[2]q = %[3]q + %[4]q = %[5]q + } +} +`, rName, tagKey1, tagValue1, tagKey2, tagValue2) +} diff --git a/aws/resource_aws_sagemaker_test.go b/aws/resource_aws_sagemaker_test.go index c451029b102..35d2dba4c8a 100644 --- a/aws/resource_aws_sagemaker_test.go +++ b/aws/resource_aws_sagemaker_test.go @@ -30,17 +30,18 @@ func TestAccAWSSagemaker_serial(t *testing.T) { "resourceSpec": testAccAWSSagemakerApp_resourceSpec, }, "Domain": { - "basic": testAccAWSSagemakerDomain_basic, - "disappears": testAccAWSSagemakerDomain_tags, - "tags": testAccAWSSagemakerDomain_disappears, - "tensorboardAppSettings": testAccAWSSagemakerDomain_tensorboardAppSettings, - "tensorboardAppSettingsWithImage": testAccAWSSagemakerDomain_tensorboardAppSettingsWithImage, - "kernelGatewayAppSettings": testAccAWSSagemakerDomain_kernelGatewayAppSettings, - "kernelGatewayAppSettings_customImage": testAccAWSSagemakerDomain_kernelGatewayAppSettings_customImage, - "jupyterServerAppSettings": testAccAWSSagemakerDomain_jupyterServerAppSettings, - "kms": testAccAWSSagemakerDomain_kms, - "securityGroup": testAccAWSSagemakerDomain_securityGroup, - "sharingSettings": testAccAWSSagemakerDomain_sharingSettings, + "basic": testAccAWSSagemakerDomain_basic, + "disappears": testAccAWSSagemakerDomain_tags, + "tags": testAccAWSSagemakerDomain_disappears, + "tensorboardAppSettings": testAccAWSSagemakerDomain_tensorboardAppSettings, + "tensorboardAppSettingsWithImage": testAccAWSSagemakerDomain_tensorboardAppSettingsWithImage, + "kernelGatewayAppSettings": testAccAWSSagemakerDomain_kernelGatewayAppSettings, + "kernelGatewayAppSettings_customImage": testAccAWSSagemakerDomain_kernelGatewayAppSettings_customImage, + "kernelGatewayAppSettings_lifecycleConfig": testAccAWSSagemakerDomain_kernelGatewayAppSettings_lifecycleConfig, + "jupyterServerAppSettings": testAccAWSSagemakerDomain_jupyterServerAppSettings, + "kms": testAccAWSSagemakerDomain_kms, + "securityGroup": testAccAWSSagemakerDomain_securityGroup, + "sharingSettings": testAccAWSSagemakerDomain_sharingSettings, }, "FlowDefinition": { "basic": testAccAWSSagemakerFlowDefinition_basic, @@ -56,7 +57,8 @@ func TestAccAWSSagemaker_serial(t *testing.T) { "tensorboardAppSettings": testAccAWSSagemakerUserProfile_tensorboardAppSettings, "tensorboardAppSettingsWithImage": testAccAWSSagemakerUserProfile_tensorboardAppSettingsWithImage, "kernelGatewayAppSettings": testAccAWSSagemakerUserProfile_kernelGatewayAppSettings, - "jupyterServerAppSettings": testAccAWSSagemakerUserProfile_jupyterServerAppSettings, + "kernelGatewayAppSettings_lifecycleConfig": testAccAWSSagemakerUserProfile_kernelGatewayAppSettings_lifecycleconfig, + "jupyterServerAppSettings": testAccAWSSagemakerUserProfile_jupyterServerAppSettings, }, "Workforce": { "disappears": testAccAWSSagemakerWorkforce_disappears, diff --git a/aws/resource_aws_sagemaker_user_profile.go b/aws/resource_aws_sagemaker_user_profile.go index 6bb01d2429b..42d6d9329b5 100644 --- a/aws/resource_aws_sagemaker_user_profile.go +++ b/aws/resource_aws_sagemaker_user_profile.go @@ -152,6 +152,14 @@ func resourceAwsSagemakerUserProfile() *schema.Resource { }, }, }, + "lifecycle_config_arns": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validateArn, + }, + }, }, }, }, @@ -181,6 +189,14 @@ func resourceAwsSagemakerUserProfile() *schema.Resource { }, }, }, + "lifecycle_config_arns": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validateArn, + }, + }, "custom_image": { Type: schema.TypeList, Optional: true, diff --git a/aws/resource_aws_sagemaker_user_profile_test.go b/aws/resource_aws_sagemaker_user_profile_test.go index 309c187b111..1f1c2371639 100644 --- a/aws/resource_aws_sagemaker_user_profile_test.go +++ b/aws/resource_aws_sagemaker_user_profile_test.go @@ -234,6 +234,37 @@ func testAccAWSSagemakerUserProfile_kernelGatewayAppSettings(t *testing.T) { }) } +func testAccAWSSagemakerUserProfile_kernelGatewayAppSettings_lifecycleconfig(t *testing.T) { + var domain sagemaker.DescribeUserProfileOutput + rName := acctest.RandomWithPrefix("tf-acc-test") + resourceName := "aws_sagemaker_user_profile.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ErrorCheck: testAccErrorCheck(t, sagemaker.EndpointsID), + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSSagemakerUserProfileDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSSagemakerUserProfileConfigKernelGatewayAppSettingsLifecycleConfig(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSSagemakerUserProfileExists(resourceName, &domain), + resource.TestCheckResourceAttr(resourceName, "user_settings.#", "1"), + resource.TestCheckResourceAttr(resourceName, "user_settings.0.kernel_gateway_app_settings.#", "1"), + resource.TestCheckResourceAttr(resourceName, "user_settings.0.kernel_gateway_app_settings.0.lifecycle_config_arns.#", "1"), + resource.TestCheckResourceAttr(resourceName, "user_settings.0.kernel_gateway_app_settings.0.default_resource_spec.#", "1"), + resource.TestCheckResourceAttr(resourceName, "user_settings.0.kernel_gateway_app_settings.0.default_resource_spec.0.instance_type", "ml.t3.micro"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + func testAccAWSSagemakerUserProfile_jupyterServerAppSettings(t *testing.T) { var domain sagemaker.DescribeUserProfileOutput rName := acctest.RandomWithPrefix("tf-acc-test") @@ -514,3 +545,30 @@ resource "aws_sagemaker_user_profile" "test" { } `, rName) } + +func testAccAWSSagemakerUserProfileConfigKernelGatewayAppSettingsLifecycleConfig(rName string) string { + return testAccAWSSagemakerUserProfileConfigBase(rName) + fmt.Sprintf(` +resource "aws_sagemaker_studio_lifecycle_config" "test" { + studio_lifecycle_config_name = %[1]q + studio_lifecycle_config_app_type = "JupyterServer" + studio_lifecycle_config_content = base64encode("echo Hello") +} + +resource "aws_sagemaker_user_profile" "test" { + domain_id = aws_sagemaker_domain.test.id + user_profile_name = %[1]q + + user_settings { + execution_role = aws_iam_role.test.arn + + kernel_gateway_app_settings { + default_resource_spec { + instance_type = "ml.t3.micro" + } + + lifecycle_config_arns = [aws_sagemaker_studio_lifecycle_config.test.arn] + } + } +} +`, rName) +} diff --git a/website/docs/r/sagemaker_domain.html.markdown b/website/docs/r/sagemaker_domain.html.markdown index af437b6ce21..3f8c5726981 100644 --- a/website/docs/r/sagemaker_domain.html.markdown +++ b/website/docs/r/sagemaker_domain.html.markdown @@ -123,10 +123,12 @@ The following arguments are supported: * `default_resource_spec` - (Optional) The default instance type and the Amazon Resource Name (ARN) of the SageMaker image created on the instance. see [Default Resource Spec](#default-resource-spec) below. * `custom_image` - (Optional) A list of custom SageMaker images that are configured to run as a KernelGateway app. see [Custom Image](#custom-image) below. +* `lifecycle_config_arns` - (Optional) The Amazon Resource Name (ARN) of the Lifecycle Configurations. #### Jupyter Server App Settings * `default_resource_spec` - (Optional) The default instance type and the Amazon Resource Name (ARN) of the SageMaker image created on the instance. see [Default Resource Spec](#default-resource-spec) below. +* `lifecycle_config_arns` - (Optional) The Amazon Resource Name (ARN) of the Lifecycle Configurations. ##### Default Resource Spec diff --git a/website/docs/r/sagemaker_studio_lifecycle_config.html.markdown b/website/docs/r/sagemaker_studio_lifecycle_config.html.markdown new file mode 100644 index 00000000000..fd0f9f0882d --- /dev/null +++ b/website/docs/r/sagemaker_studio_lifecycle_config.html.markdown @@ -0,0 +1,48 @@ +--- +subcategory: "Sagemaker" +layout: "aws" +page_title: "AWS: aws_sagemaker_studio_lifecycle_config" +description: |- + Provides a Sagemaker Studio Lifecycle Config resource. +--- + +# Resource: aws_sagemaker_studio_lifecycle_config + +Provides a Sagemaker Studio Lifecycle Config resource. + +## Example Usage + +### Basic usage + +```terraform +resource "aws_sagemaker_studio_lifecycle_config" "example" { + studio_lifecycle_config_name = "example" + studio_lifecycle_config_app_type = "JupyterServer" + studio_lifecycle_config_content = base64encode("echo Hello") +} +``` + +## Argument Reference + +The following arguments are supported: + +* `studio_lifecycle_config_name` - (Required) The name of the Studio Lifecycle Configuration to create. +* `studio_lifecycle_config_app_type` - (Required) The App type that the Lifecycle Configuration is attached to. Valid values are `JupyterServer` and `KernelGateway`. +* `studio_lifecycle_config_content` - (Required) The content of your Studio Lifecycle Configuration script. This content must be base64 encoded. +* `tags` - (Optional) A map of tags to assign to the resource. If configured with a provider [`default_tags` configuration block](/docs/providers/aws/index.html#default_tags-configuration-block) present, tags with matching keys will overwrite those defined at the provider-level. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `id` - The name of the Studio Lifecycle Config. +* `arn` - The Amazon Resource Name (ARN) assigned by AWS to this Studio Lifecycle Config. +* `tags_all` - A map of tags assigned to the resource, including those inherited from the provider [`default_tags` configuration block](/docs/providers/aws/index.html#default_tags-configuration-block). + +## Import + +Sagemaker Code Studio Lifecycle Configs can be imported using the `studio_lifecycle_config_name`, e.g. + +``` +$ terraform import aws_sagemaker_studio_lifecycle_config.example example +``` diff --git a/website/docs/r/sagemaker_user_profile.html.markdown b/website/docs/r/sagemaker_user_profile.html.markdown index 8a38fa0fe6d..361dbb4cb32 100644 --- a/website/docs/r/sagemaker_user_profile.html.markdown +++ b/website/docs/r/sagemaker_user_profile.html.markdown @@ -55,10 +55,12 @@ The following arguments are supported: * `default_resource_spec` - (Optional) The default instance type and the Amazon Resource Name (ARN) of the SageMaker image created on the instance. see [Default Resource Spec](#default-resource-spec) below. * `custom_image` - (Optional) A list of custom SageMaker images that are configured to run as a KernelGateway app. see [Custom Image](#custom-image) below. +* `lifecycle_config_arns` - (Optional) The Amazon Resource Name (ARN) of the Lifecycle Configurations. #### Jupyter Server App Settings * `default_resource_spec` - (Optional) The default instance type and the Amazon Resource Name (ARN) of the SageMaker image created on the instance. see [Default Resource Spec](#default-resource-spec) below. +* `lifecycle_config_arns` - (Optional) The Amazon Resource Name (ARN) of the Lifecycle Configurations. ##### Default Resource Spec