diff --git a/.changelog/42753.txt b/.changelog/42753.txt new file mode 100644 index 000000000000..ce7240ae0ce4 --- /dev/null +++ b/.changelog/42753.txt @@ -0,0 +1,3 @@ +```release-note:bug +resource/aws_sagemaker_app_image_config: Fixes non-starting SageMaker spaces by requiring exactly one kernel (can be empty) +``` \ No newline at end of file diff --git a/internal/service/sagemaker/app_image_config.go b/internal/service/sagemaker/app_image_config.go index 2df0852c2114..d34eafed2a36 100644 --- a/internal/service/sagemaker/app_image_config.go +++ b/internal/service/sagemaker/app_image_config.go @@ -5,6 +5,7 @@ package sagemaker import ( "context" + "fmt" "log" "github.com/YakDriver/regexache" @@ -36,6 +37,19 @@ func resourceAppImageConfig() *schema.Resource { StateContext: schema.ImportStatePassthroughContext, }, + CustomizeDiff: schema.CustomizeDiffFunc(func(ctx context.Context, d *schema.ResourceDiff, meta any) error { + // Ensure at least one of the three config blocks is provided + codeEditorConfig := d.Get("code_editor_app_image_config").([]any) + jupyterLabConfig := d.Get("jupyter_lab_image_config").([]any) + kernelGatewayConfig := d.Get("kernel_gateway_image_config").([]any) + + if len(codeEditorConfig) == 0 && len(jupyterLabConfig) == 0 && len(kernelGatewayConfig) == 0 { + return fmt.Errorf("one of code_editor_app_image_config, jupyter_lab_image_config, or kernel_gateway_image_config must be configured") + } + + return nil + }), + Schema: map[string]*schema.Schema{ names.AttrARN: { Type: schema.TypeString, @@ -51,9 +65,10 @@ func resourceAppImageConfig() *schema.Resource { ), }, "code_editor_app_image_config": { - Type: schema.TypeList, - Optional: true, - MaxItems: 1, + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + ConflictsWith: []string{"jupyter_lab_image_config", "kernel_gateway_image_config"}, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "container_config": { @@ -114,9 +129,10 @@ func resourceAppImageConfig() *schema.Resource { }, }, "jupyter_lab_image_config": { - Type: schema.TypeList, - Optional: true, - MaxItems: 1, + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + ConflictsWith: []string{"code_editor_app_image_config", "kernel_gateway_image_config"}, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "container_config": { @@ -177,9 +193,10 @@ func resourceAppImageConfig() *schema.Resource { }, }, "kernel_gateway_image_config": { - Type: schema.TypeList, - Optional: true, - MaxItems: 1, + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + ConflictsWith: []string{"code_editor_app_image_config", "jupyter_lab_image_config"}, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "file_system_config": { @@ -250,16 +267,16 @@ func resourceAppImageConfigCreate(ctx context.Context, d *schema.ResourceData, m Tags: getTagsIn(ctx), } - if v, ok := d.GetOk("code_editor_app_image_config"); ok && len(v.([]any)) > 0 { - input.CodeEditorAppImageConfig = expandCodeEditorAppImageConfig(v.([]any)) + if _, ok := d.GetOk("code_editor_app_image_config"); ok { + input.CodeEditorAppImageConfig = expandCodeEditorAppImageConfig(d.Get("code_editor_app_image_config").([]any)) } - if v, ok := d.GetOk("jupyter_lab_image_config"); ok && len(v.([]any)) > 0 { - input.JupyterLabAppImageConfig = expandJupyterLabAppImageConfig(v.([]any)) + if _, ok := d.GetOk("jupyter_lab_image_config"); ok { + input.JupyterLabAppImageConfig = expandJupyterLabAppImageConfig(d.Get("jupyter_lab_image_config").([]any)) } - if v, ok := d.GetOk("kernel_gateway_image_config"); ok && len(v.([]any)) > 0 { - input.KernelGatewayImageConfig = expandKernelGatewayImageConfig(v.([]any)) + if _, ok := d.GetOk("kernel_gateway_image_config"); ok { + input.KernelGatewayImageConfig = expandKernelGatewayImageConfig(d.Get("kernel_gateway_image_config").([]any)) } _, err := conn.CreateAppImageConfig(ctx, input) @@ -317,20 +334,20 @@ func resourceAppImageConfigUpdate(ctx context.Context, d *schema.ResourceData, m } if d.HasChange("code_editor_app_image_config") { - if v, ok := d.GetOk("code_editor_app_image_config"); ok && len(v.([]any)) > 0 { - input.CodeEditorAppImageConfig = expandCodeEditorAppImageConfig(v.([]any)) + if _, ok := d.GetOk("code_editor_app_image_config"); ok { + input.CodeEditorAppImageConfig = expandCodeEditorAppImageConfig(d.Get("code_editor_app_image_config").([]any)) } } if d.HasChange("kernel_gateway_image_config") { - if v, ok := d.GetOk("kernel_gateway_image_config"); ok && len(v.([]any)) > 0 { - input.KernelGatewayImageConfig = expandKernelGatewayImageConfig(v.([]any)) + if _, ok := d.GetOk("kernel_gateway_image_config"); ok { + input.KernelGatewayImageConfig = expandKernelGatewayImageConfig(d.Get("kernel_gateway_image_config").([]any)) } } if d.HasChange("jupyter_lab_image_config") { - if v, ok := d.GetOk("jupyter_lab_image_config"); ok && len(v.([]any)) > 0 { - input.JupyterLabAppImageConfig = expandJupyterLabAppImageConfig(v.([]any)) + if _, ok := d.GetOk("jupyter_lab_image_config"); ok { + input.JupyterLabAppImageConfig = expandJupyterLabAppImageConfig(d.Get("jupyter_lab_image_config").([]any)) } } @@ -388,14 +405,20 @@ func findAppImageConfigByName(ctx context.Context, conn *sagemaker.Client, appIm } func expandKernelGatewayImageConfig(l []any) *awstypes.KernelGatewayImageConfig { - if len(l) == 0 || l[0] == nil { + if len(l) == 0 { return nil } - m := l[0].(map[string]any) - + // Always create a config object even if the block is empty config := &awstypes.KernelGatewayImageConfig{} + // If the block is nil, return the empty config + if l[0] == nil { + return config + } + + m := l[0].(map[string]any) + if v, ok := m["kernel_spec"].([]any); ok && len(v) > 0 { config.KernelSpecs = expandKernelGatewayImageConfigKernelSpecs(v) } @@ -408,18 +431,28 @@ func expandKernelGatewayImageConfig(l []any) *awstypes.KernelGatewayImageConfig } func expandFileSystemConfig(l []any) *awstypes.FileSystemConfig { - if len(l) == 0 || l[0] == nil { + if len(l) == 0 { return nil } - m := l[0].(map[string]any) - + // Always create a config object even if the block is empty config := &awstypes.FileSystemConfig{ - DefaultGid: aws.Int32(int32(m["default_gid"].(int))), - DefaultUid: aws.Int32(int32(m["default_uid"].(int))), - MountPath: aws.String(m["mount_path"].(string)), + DefaultGid: aws.Int32(100), // Default values + DefaultUid: aws.Int32(1000), // Default values + MountPath: aws.String("/home/sagemaker-user"), // Default value + } + + // If the block is nil, return the config with default values + if l[0] == nil { + return config } + m := l[0].(map[string]any) + + config.DefaultGid = aws.Int32(int32(m["default_gid"].(int))) + config.DefaultUid = aws.Int32(int32(m["default_uid"].(int))) + config.MountPath = aws.String(m["mount_path"].(string)) + return config } @@ -502,14 +535,20 @@ func flattenKernelGatewayImageConfigKernelSpecs(kernelSpecs []awstypes.KernelSpe } func expandCodeEditorAppImageConfig(l []any) *awstypes.CodeEditorAppImageConfig { - if len(l) == 0 || l[0] == nil { + if len(l) == 0 { return nil } - m := l[0].(map[string]any) - + // Always create a config object even if the block is empty config := &awstypes.CodeEditorAppImageConfig{} + // If the block is nil, return the empty config + if l[0] == nil { + return config + } + + m := l[0].(map[string]any) + if v, ok := m["container_config"].([]any); ok && len(v) > 0 { config.ContainerConfig = expandContainerConfig(v) } @@ -540,14 +579,20 @@ func flattenCodeEditorAppImageConfig(config *awstypes.CodeEditorAppImageConfig) } func expandJupyterLabAppImageConfig(l []any) *awstypes.JupyterLabAppImageConfig { - if len(l) == 0 || l[0] == nil { + if len(l) == 0 { return nil } - m := l[0].(map[string]any) - + // Always create a config object even if the block is empty config := &awstypes.JupyterLabAppImageConfig{} + // If the block is nil, return the empty config + if l[0] == nil { + return config + } + + m := l[0].(map[string]any) + if v, ok := m["container_config"].([]any); ok && len(v) > 0 { config.ContainerConfig = expandContainerConfig(v) } @@ -578,14 +623,20 @@ func flattenJupyterLabAppImageConfig(config *awstypes.JupyterLabAppImageConfig) } func expandContainerConfig(l []any) *awstypes.ContainerConfig { - if len(l) == 0 || l[0] == nil { + if len(l) == 0 { return nil } - m := l[0].(map[string]any) - + // Always create a config object even if the block is empty config := &awstypes.ContainerConfig{} + // If the block is nil, return the empty config + if l[0] == nil { + return config + } + + m := l[0].(map[string]any) + if v, ok := m["container_arguments"].([]any); ok && len(v) > 0 { config.ContainerArguments = flex.ExpandStringValueList(v) } diff --git a/internal/service/sagemaker/app_image_config_test.go b/internal/service/sagemaker/app_image_config_test.go index 408d5515b55f..001a47415ad2 100644 --- a/internal/service/sagemaker/app_image_config_test.go +++ b/internal/service/sagemaker/app_image_config_test.go @@ -19,38 +19,6 @@ import ( "github.com/hashicorp/terraform-provider-aws/names" ) -func TestAccSageMakerAppImageConfig_basic(t *testing.T) { - ctx := acctest.Context(t) - var config sagemaker.DescribeAppImageConfigOutput - rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) - resourceName := "aws_sagemaker_app_image_config.test" - - resource.ParallelTest(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(ctx, t) }, - ErrorCheck: acctest.ErrorCheck(t, names.SageMakerServiceID), - ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, - CheckDestroy: testAccCheckAppImageConfigDestroy(ctx), - Steps: []resource.TestStep{ - { - Config: testAccAppImageConfigConfig_basic(rName), - Check: resource.ComposeTestCheckFunc( - testAccCheckAppImageConfigExists(ctx, resourceName, &config), - resource.TestCheckResourceAttr(resourceName, "app_image_config_name", rName), - acctest.CheckResourceAttrRegionalARN(ctx, resourceName, names.AttrARN, "sagemaker", fmt.Sprintf("app-image-config/%s", rName)), - resource.TestCheckResourceAttr(resourceName, "kernel_gateway_image_config.#", "0"), - resource.TestCheckResourceAttr(resourceName, "jupyter_lab_image_config.#", "0"), - resource.TestCheckResourceAttr(resourceName, acctest.CtTagsPercent, "0"), - ), - }, - { - ResourceName: resourceName, - ImportState: true, - ImportStateVerify: true, - }, - }, - }) -} - func TestAccSageMakerAppImageConfig_KernelGatewayImage_kernelSpecs(t *testing.T) { ctx := acctest.Context(t) var config sagemaker.DescribeAppImageConfigOutput @@ -288,30 +256,6 @@ func TestAccSageMakerAppImageConfig_tags(t *testing.T) { }) } -func TestAccSageMakerAppImageConfig_disappears(t *testing.T) { - ctx := acctest.Context(t) - var config sagemaker.DescribeAppImageConfigOutput - rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) - resourceName := "aws_sagemaker_app_image_config.test" - - resource.ParallelTest(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(ctx, t) }, - ErrorCheck: acctest.ErrorCheck(t, names.SageMakerServiceID), - ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, - CheckDestroy: testAccCheckAppImageConfigDestroy(ctx), - Steps: []resource.TestStep{ - { - Config: testAccAppImageConfigConfig_basic(rName), - Check: resource.ComposeTestCheckFunc( - testAccCheckAppImageConfigExists(ctx, resourceName, &config), - acctest.CheckResourceDisappears(ctx, acctest.Provider, tfsagemaker.ResourceAppImageConfig(), resourceName), - ), - ExpectNonEmptyPlan: true, - }, - }, - }) -} - func testAccCheckAppImageConfigDestroy(ctx context.Context) resource.TestCheckFunc { return func(s *terraform.State) error { conn := acctest.Provider.Meta().(*conns.AWSClient).SageMakerClient(ctx) @@ -363,14 +307,6 @@ func testAccCheckAppImageConfigExists(ctx context.Context, n string, config *sag } } -func testAccAppImageConfigConfig_basic(rName string) string { - return fmt.Sprintf(` -resource "aws_sagemaker_app_image_config" "test" { - app_image_config_name = %[1]q -} -`, rName) -} - func testAccAppImageConfigConfig_kernelGatewayKernalSpecs1(rName string) string { return fmt.Sprintf(` resource "aws_sagemaker_app_image_config" "test" { @@ -441,6 +377,8 @@ func testAccAppImageConfigConfig_tags1(rName, tagKey1, tagValue1 string) string resource "aws_sagemaker_app_image_config" "test" { app_image_config_name = %[1]q + code_editor_app_image_config {} + tags = { %[2]q = %[3]q } @@ -453,6 +391,8 @@ func testAccAppImageConfigConfig_tags2(rName, tagKey1, tagValue1, tagKey2, tagVa resource "aws_sagemaker_app_image_config" "test" { app_image_config_name = %[1]q + code_editor_app_image_config {} + tags = { %[2]q = %[3]q %[4]q = %[5]q diff --git a/website/docs/r/sagemaker_app_image_config.html.markdown b/website/docs/r/sagemaker_app_image_config.html.markdown index 8731b5b695a8..47e0f8997595 100644 --- a/website/docs/r/sagemaker_app_image_config.html.markdown +++ b/website/docs/r/sagemaker_app_image_config.html.markdown @@ -26,6 +26,16 @@ resource "aws_sagemaker_app_image_config" "test" { } ``` +### Using Code Editor with empty configuration + +```terraform +resource "aws_sagemaker_app_image_config" "test" { + app_image_config_name = "example" + + code_editor_app_image_config {} +} +``` + ### Default File System Config ```terraform @@ -48,11 +58,13 @@ This resource supports the following arguments: * `region` - (Optional) Region where this resource will be [managed](https://docs.aws.amazon.com/general/latest/gr/rande.html#regional-endpoints). Defaults to the Region set in the [provider configuration](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#aws-configuration-reference). * `app_image_config_name` - (Required) The name of the App Image Config. -* `code_editor_app_image_config` - (Optional) The CodeEditorAppImageConfig. You can only specify one image kernel in the AppImageConfig API. This kernel is shown to users before the image starts. After the image runs, all kernels are visible in Code Editor. See [Code Editor App Image Config](#code-editor-app-image-config) details below. -* `jupyter_lab_image_config` - (Optional) The JupyterLabAppImageConfig. You can only specify one image kernel in the AppImageConfig API. This kernel is shown to users before the image starts. After the image runs, all kernels are visible in JupyterLab. See [Jupyter Lab Image Config](#jupyter-lab-image-config) details below. +* `code_editor_app_image_config` - (Optional) The CodeEditorAppImageConfig. See [Code Editor App Image Config](#code-editor-app-image-config) details below. +* `jupyter_lab_image_config` - (Optional) The JupyterLabAppImageConfig. See [Jupyter Lab Image Config](#jupyter-lab-image-config) details below. * `kernel_gateway_image_config` - (Optional) The configuration for the file system and kernels in a SageMaker AI image running as a KernelGateway app. See [Kernel Gateway Image Config](#kernel-gateway-image-config) details below. * `tags` - (Optional) A map of tags to assign to the resource. If configured with a provider [`default_tags` configuration block](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#default_tags-configuration-block) present, tags with matching keys will overwrite those defined at the provider-level. +~> **NOTE:** Exactly one of `code_editor_app_image_config`, `jupyter_lab_image_config`, or `kernel_gateway_image_config` must be configured. Empty blocks (e.g., `code_editor_app_image_config {}`) are valid configurations. + ### Code Editor App Image Config * `container_config` - (Optional) The configuration used to run the application image container. See [Container Config](#container-config) details below.