diff --git a/.changelog/22427.txt b/.changelog/22427.txt new file mode 100644 index 00000000000..c40e50486f1 --- /dev/null +++ b/.changelog/22427.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +aws_sagemaker_device +``` \ No newline at end of file diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 21a4102e289..b94d11a2652 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -1570,6 +1570,7 @@ func Provider() *schema.Provider { "aws_sagemaker_app": sagemaker.ResourceApp(), "aws_sagemaker_app_image_config": sagemaker.ResourceAppImageConfig(), "aws_sagemaker_code_repository": sagemaker.ResourceCodeRepository(), + "aws_sagemaker_device": sagemaker.ResourceDevice(), "aws_sagemaker_device_fleet": sagemaker.ResourceDeviceFleet(), "aws_sagemaker_domain": sagemaker.ResourceDomain(), "aws_sagemaker_endpoint": sagemaker.ResourceEndpoint(), diff --git a/internal/service/fsx/sweep.go b/internal/service/fsx/sweep.go index d06bb065af9..bf99725619b 100644 --- a/internal/service/fsx/sweep.go +++ b/internal/service/fsx/sweep.go @@ -48,7 +48,7 @@ func init() { F: sweepFSXOpenzfsFileSystems, }) - resource.AddTestSweepers("aws_fsx_ontap_volume", &resource.Sweeper{ + resource.AddTestSweepers("aws_fsx_openzfs_volume", &resource.Sweeper{ Name: "aws_fsx_openzfs_volume", F: sweepFSXOpenzfsVolume, }) diff --git a/internal/service/sagemaker/device.go b/internal/service/sagemaker/device.go new file mode 100644 index 00000000000..04f9e18ffb8 --- /dev/null +++ b/internal/service/sagemaker/device.go @@ -0,0 +1,218 @@ +package sagemaker + +import ( + "fmt" + "log" + "regexp" + "strings" + + "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/hashicorp/terraform-provider-aws/internal/conns" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" +) + +func ResourceDevice() *schema.Resource { + return &schema.Resource{ + Create: resourceDeviceCreate, + Read: resourceDeviceRead, + Update: resourceDeviceUpdate, + Delete: resourceDeviceDelete, + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Schema: map[string]*schema.Schema{ + "arn": { + Type: schema.TypeString, + Computed: true, + }, + "agent_version": { + Type: schema.TypeString, + Computed: true, + }, + "device_fleet_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]){0,62}$`), "Valid characters are a-z, A-Z, 0-9, and - (hyphen)."), + ), + }, + "device": { + Type: schema.TypeList, + Required: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "description": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringLenBetween(1, 40), + }, + "device_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringLenBetween(1, 63), + }, + "iot_thing_name": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringLenBetween(1, 128), + }, + }, + }, + }, + }, + } +} + +func resourceDeviceCreate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*conns.AWSClient).SageMakerConn + + name := d.Get("device_fleet_name").(string) + input := &sagemaker.RegisterDevicesInput{ + DeviceFleetName: aws.String(name), + Devices: expandSagemakerDevice(d.Get("device").([]interface{})), + } + + _, err := conn.RegisterDevices(input) + if err != nil { + return fmt.Errorf("error creating SageMaker Device %s: %w", name, err) + } + + d.SetId(fmt.Sprintf("%s/%s", name, aws.StringValue(input.Devices[0].DeviceName))) + + return resourceDeviceRead(d, meta) +} + +func resourceDeviceRead(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*conns.AWSClient).SageMakerConn + + deviceFleetName, deviceName, err := DecodeDeviceId(d.Id()) + if err != nil { + return err + } + device, err := FindDeviceByName(conn, deviceFleetName, deviceName) + if !d.IsNewResource() && tfresource.NotFound(err) { + log.Printf("[WARN] Unable to find SageMaker Device (%s); removing from state", d.Id()) + d.SetId("") + return nil + } + + if err != nil { + return fmt.Errorf("error reading SageMaker Device (%s): %w", d.Id(), err) + } + + arn := aws.StringValue(device.DeviceArn) + d.Set("device_fleet_name", device.DeviceFleetName) + d.Set("agent_version", device.AgentVersion) + d.Set("arn", arn) + + if err := d.Set("device", flattenSagemakerDevice(device)); err != nil { + return fmt.Errorf("error setting device for Sagemaker Device (%s): %w", d.Id(), err) + } + + return nil +} + +func resourceDeviceUpdate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*conns.AWSClient).SageMakerConn + + deviceFleetName, _, err := DecodeDeviceId(d.Id()) + if err != nil { + return err + } + + input := &sagemaker.UpdateDevicesInput{ + DeviceFleetName: aws.String(deviceFleetName), + Devices: expandSagemakerDevice(d.Get("device").([]interface{})), + } + + log.Printf("[DEBUG] sagemaker Device update config: %s", input.String()) + _, err = conn.UpdateDevices(input) + if err != nil { + return fmt.Errorf("error updating SageMaker Device: %w", err) + } + + return resourceDeviceRead(d, meta) +} + +func resourceDeviceDelete(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*conns.AWSClient).SageMakerConn + + deviceFleetName, deviceName, err := DecodeDeviceId(d.Id()) + if err != nil { + return err + } + + input := &sagemaker.DeregisterDevicesInput{ + DeviceFleetName: aws.String(deviceFleetName), + DeviceNames: []*string{aws.String(deviceName)}, + } + + if _, err := conn.DeregisterDevices(input); err != nil { + if tfawserr.ErrMessageContains(err, ErrCodeValidationException, "Device with name") || + tfawserr.ErrMessageContains(err, ErrCodeValidationException, "No device fleet with name") { + return nil + } + return fmt.Errorf("error deleting SageMaker Device (%s): %w", d.Id(), err) + } + + return nil +} + +func expandSagemakerDevice(l []interface{}) []*sagemaker.Device { + if len(l) == 0 || l[0] == nil { + return nil + } + + m := l[0].(map[string]interface{}) + + config := &sagemaker.Device{ + DeviceName: aws.String(m["device_name"].(string)), + } + + if v, ok := m["description"].(string); ok && v != "" { + config.Description = aws.String(m["description"].(string)) + } + + if v, ok := m["iot_thing_name"].(string); ok && v != "" { + config.IotThingName = aws.String(m["iot_thing_name"].(string)) + } + + return []*sagemaker.Device{config} +} + +func flattenSagemakerDevice(config *sagemaker.DescribeDeviceOutput) []map[string]interface{} { + if config == nil { + return []map[string]interface{}{} + } + + m := map[string]interface{}{ + "device_name": aws.StringValue(config.DeviceName), + } + + if config.Description != nil { + m["description"] = aws.StringValue(config.Description) + } + + if config.IotThingName != nil { + m["iot_thing_name"] = aws.StringValue(config.IotThingName) + } + + return []map[string]interface{}{m} +} + +func DecodeDeviceId(id string) (string, string, error) { + iDParts := strings.Split(id, "/") + if len(iDParts) != 2 { + return "", "", fmt.Errorf("unexpected format of ID (%q), expected DEVICE-FLEET-NAME:DEVICE-NAME", id) + } + return iDParts[0], iDParts[1], nil +} diff --git a/internal/service/sagemaker/device_test.go b/internal/service/sagemaker/device_test.go new file mode 100644 index 00000000000..18d6d6d8b8d --- /dev/null +++ b/internal/service/sagemaker/device_test.go @@ -0,0 +1,284 @@ +package sagemaker_test + +import ( + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/sagemaker" + sdkacctest "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/hashicorp/terraform-provider-aws/internal/acctest" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + tfsagemaker "github.com/hashicorp/terraform-provider-aws/internal/service/sagemaker" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" +) + +func TestAccSageMakerDevice_basic(t *testing.T) { + var device sagemaker.DescribeDeviceOutput + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_sagemaker_device.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ErrorCheck: acctest.ErrorCheck(t, sagemaker.EndpointsID), + Providers: acctest.Providers, + CheckDestroy: testAccCheckDeviceDestroy, + Steps: []resource.TestStep{ + { + Config: testAccDeviceBasicConfig(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckDeviceExists(resourceName, &device), + resource.TestCheckResourceAttr(resourceName, "device_fleet_name", rName), + acctest.CheckResourceAttrRegionalARN(resourceName, "arn", "sagemaker", fmt.Sprintf("device-fleet/%[1]s/device/%[1]s", rName)), + resource.TestCheckResourceAttr(resourceName, "device.#", "1"), + resource.TestCheckResourceAttr(resourceName, "device.0.device_name", rName), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccSageMakerDevice_description(t *testing.T) { + var device sagemaker.DescribeDeviceOutput + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_sagemaker_device.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ErrorCheck: acctest.ErrorCheck(t, sagemaker.EndpointsID), + Providers: acctest.Providers, + CheckDestroy: testAccCheckDeviceDestroy, + Steps: []resource.TestStep{ + { + Config: testAccDeviceDescription(rName, rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckDeviceExists(resourceName, &device), + resource.TestCheckResourceAttr(resourceName, "device.0.description", rName), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccDeviceDescription(rName, "test"), + Check: resource.ComposeTestCheckFunc( + testAccCheckDeviceExists(resourceName, &device), + resource.TestCheckResourceAttr(resourceName, "device.0.description", "test"), + ), + }, + }, + }) +} + +func TestAccSageMakerDevice_disappears(t *testing.T) { + var device sagemaker.DescribeDeviceOutput + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_sagemaker_device.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ErrorCheck: acctest.ErrorCheck(t, sagemaker.EndpointsID), + Providers: acctest.Providers, + CheckDestroy: testAccCheckDeviceDestroy, + Steps: []resource.TestStep{ + { + Config: testAccDeviceBasicConfig(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckDeviceExists(resourceName, &device), + acctest.CheckResourceDisappears(acctest.Provider, tfsagemaker.ResourceDevice(), resourceName), + acctest.CheckResourceDisappears(acctest.Provider, tfsagemaker.ResourceDevice(), resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func TestAccSageMakerDevice_disappears_fleet(t *testing.T) { + var device sagemaker.DescribeDeviceOutput + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_sagemaker_device.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ErrorCheck: acctest.ErrorCheck(t, sagemaker.EndpointsID), + Providers: acctest.Providers, + CheckDestroy: testAccCheckDeviceDestroy, + Steps: []resource.TestStep{ + { + Config: testAccDeviceBasicConfig(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckDeviceExists(resourceName, &device), + acctest.CheckResourceDisappears(acctest.Provider, tfsagemaker.ResourceDeviceFleet(), "aws_sagemaker_device_fleet.test"), + acctest.CheckResourceDisappears(acctest.Provider, tfsagemaker.ResourceDevice(), resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func testAccCheckDeviceDestroy(s *terraform.State) error { + conn := acctest.Provider.Meta().(*conns.AWSClient).SageMakerConn + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_sagemaker_device" { + continue + } + + deviceFleetName, deviceName, err := tfsagemaker.DecodeDeviceId(rs.Primary.ID) + if err != nil { + return err + } + + device, err := tfsagemaker.FindDeviceByName(conn, deviceFleetName, deviceName) + if tfresource.NotFound(err) { + continue + } + + if err != nil { + return err + } + + if aws.StringValue(device.DeviceName) == deviceName && aws.StringValue(device.DeviceFleetName) == deviceFleetName { + return fmt.Errorf("Sagemaker Device %q still exists", rs.Primary.ID) + } + } + + return nil +} + +func testAccCheckDeviceExists(n string, device *sagemaker.DescribeDeviceOutput) 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 Sagmaker Device ID is set") + } + + deviceFleetName, deviceName, err := tfsagemaker.DecodeDeviceId(rs.Primary.ID) + if err != nil { + return err + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).SageMakerConn + resp, err := tfsagemaker.FindDeviceByName(conn, deviceFleetName, deviceName) + if err != nil { + return err + } + + *device = *resp + + return nil + } +} + +func testAccDeviceBaseConfig(rName string) string { + return fmt.Sprintf(` +resource "aws_s3_bucket" "test" { + bucket = %[1]q + acl = "private" + force_destroy = true +} + +data "aws_partition" "current" {} + +resource "aws_iam_role" "test" { + name = %[1]q + assume_role_policy = data.aws_iam_policy_document.test.json +} + +data "aws_iam_policy_document" "test" { + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["sagemaker.${data.aws_partition.current.dns_suffix}"] + } + } +} + +resource "aws_iam_role_policy" "test" { + name = %[1]q + role = aws_iam_role.test.id + + policy = <