diff --git a/.changelog/28699.txt b/.changelog/28699.txt new file mode 100644 index 00000000000..8a4566ed738 --- /dev/null +++ b/.changelog/28699.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +aws_lightsail_bucket_access_key +``` \ No newline at end of file diff --git a/internal/create/errors.go b/internal/create/errors.go index c66cc917432..853d3db0490 100644 --- a/internal/create/errors.go +++ b/internal/create/errors.go @@ -24,6 +24,8 @@ const ( ErrActionWaitingForCreation = "waiting for creation" ErrActionWaitingForDeletion = "waiting for delete" ErrActionWaitingForUpdate = "waiting for update" + ErrActionExpandingResourceId = "expanding resource id" + ErrActionFlatteningResourceId = "flattening resource id" ) // ProblemStandardMessage is a standardized message for reporting errors and warnings diff --git a/internal/flex/flex.go b/internal/flex/flex.go index b3daf68c7d5..782b10b2e5a 100644 --- a/internal/flex/flex.go +++ b/internal/flex/flex.go @@ -1,10 +1,18 @@ package flex import ( + "fmt" + "strings" + "github.com/aws/aws-sdk-go/aws" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) +const ( + // A common separator to be used for creating resource Ids from a combination of attributes + ResourceIdSeparator = "," +) + // Takes the result of flatmap.Expand for an array of strings // and returns a []*string func ExpandStringList(configured []interface{}) []*string { @@ -143,3 +151,59 @@ func PointersMapToStringList(pointers map[string]*string) map[string]interface{} } return list } + +// Takes a string of resource attributes separated by the ResourceIdSeparator constant and an expected number of Id Parts +// Returns a list of the resource attributes strings used to construct the unique Id or an error message if the resource id does not parse properly +func ExpandResourceId(id string, partCount int) ([]string, error) { + idParts := strings.Split(id, ResourceIdSeparator) + + if len(idParts) <= 1 { + return nil, fmt.Errorf("unexpected format for ID (%v), expected more than one part", idParts) + } + + if len(idParts) != partCount { + return nil, fmt.Errorf("unexpected format for ID (%s), expected (%d) parts separated by (%s)", id, partCount, ResourceIdSeparator) + } + + var emptyPart bool + emptyParts := make([]int, 0, partCount) + for index, part := range idParts { + if part == "" { + emptyPart = true + emptyParts = append(emptyParts, index) + } + } + + if emptyPart { + return nil, fmt.Errorf("unexpected format for ID (%[1]s), the following id parts indexes are blank (%v)", id, emptyParts) + } + + return idParts, nil +} + +// Takes a list of the resource attributes as strings used to construct the unique Id and an expected number of Id Parts +// Returns a string of resource attributes separated by the ResourceIdSeparator constant or an error message if the id parts do not parse properly +func FlattenResourceId(idParts []string, partCount int) (string, error) { + if len(idParts) <= 1 { + return "", fmt.Errorf("unexpected format for ID parts (%v), expected more than one part", idParts) + } + + if len(idParts) != partCount { + return "", fmt.Errorf("unexpected format for ID parts (%v), expected (%d) parts", idParts, partCount) + } + + var emptyPart bool + emptyParts := make([]int, 0, len(idParts)) + for index, part := range idParts { + if part == "" { + emptyPart = true + emptyParts = append(emptyParts, index) + } + } + + if emptyPart { + return "", fmt.Errorf("unexpected format for ID parts (%v), the following id parts indexes are blank (%v)", idParts, emptyParts) + } + + return strings.Join(idParts, ResourceIdSeparator), nil +} diff --git a/internal/flex/flex_test.go b/internal/flex/flex_test.go index 9cb5fa656ca..a9745a39282 100644 --- a/internal/flex/flex_test.go +++ b/internal/flex/flex_test.go @@ -2,6 +2,7 @@ package flex import ( "reflect" + "strings" "testing" "github.com/aws/aws-sdk-go/aws" @@ -39,3 +40,87 @@ func TestExpandStringListEmptyItems(t *testing.T) { expected) } } + +func TestExpandResourceId(t *testing.T) { + resourceId := "foo,bar,baz" + expandedId, _ := ExpandResourceId(resourceId, 3) + expected := []string{ + "foo", + "bar", + "baz", + } + + if !reflect.DeepEqual(expandedId, expected) { + t.Fatalf( + "Got:\n\n%#v\n\nExpected:\n\n%#v\n", + expandedId, + expected) + } +} + +func TestExpandResourceIdEmptyPart(t *testing.T) { + resourceId := "foo,,baz" + _, err := ExpandResourceId(resourceId, 3) + + if !strings.Contains(err.Error(), "format for ID (foo,,baz), the following id parts indexes are blank ([1])") { + t.Fatalf("Expected an error when parsing ResourceId with an empty part") + } +} + +func TestExpandResourceIdIncorrectPartCount(t *testing.T) { + resourceId := "foo,bar,baz" + _, err := ExpandResourceId(resourceId, 2) + + if !strings.Contains(err.Error(), "unexpected format for ID (foo,bar,baz), expected (2) parts separated by (,)") { + t.Fatalf("Expected an error when parsing ResourceId with incorrect part count") + } +} + +func TestExpandResourceIdSinglePart(t *testing.T) { + resourceId := "foo" + _, err := ExpandResourceId(resourceId, 2) + + if !strings.Contains(err.Error(), "unexpected format for ID ([foo]), expected more than one part") { + t.Fatalf("Expected an error when parsing ResourceId with single part count") + } +} + +func TestFlattenResourceId(t *testing.T) { + idParts := []string{"foo", "bar", "baz"} + flattenedId, _ := FlattenResourceId(idParts, 3) + expected := "foo,bar,baz" + + if !reflect.DeepEqual(flattenedId, expected) { + t.Fatalf( + "Got:\n\n%#v\n\nExpected:\n\n%#v\n", + flattenedId, + expected) + } +} + +func TestFlattenResourceIdEmptyPart(t *testing.T) { + idParts := []string{"foo", "", "baz"} + _, err := FlattenResourceId(idParts, 3) + + if !strings.Contains(err.Error(), "unexpected format for ID parts ([foo baz]), the following id parts indexes are blank ([1])") { + t.Fatalf("Expected an error when parsing ResourceId with an empty part") + } +} + +func TestFlattenResourceIdIncorrectPartCount(t *testing.T) { + idParts := []string{"foo", "bar", "baz"} + _, err := FlattenResourceId(idParts, 2) + + if !strings.Contains(err.Error(), "unexpected format for ID parts ([foo bar baz]), expected (2) parts") { + t.Fatalf("Expected an error when parsing ResourceId with incorrect part count") + } +} + +func TestFlattenResourceIdSinglePart(t *testing.T) { + idParts := []string{"foo"} + _, err := FlattenResourceId(idParts, 2) + + if !strings.Contains(err.Error(), "unexpected format for ID parts ([foo]), expected more than one part") { + t.Fatalf("Expected an error when parsing ResourceId with single part count") + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 6e29482f96b..6d942184358 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -1735,6 +1735,7 @@ func New(ctx context.Context) (*schema.Provider, error) { "aws_licensemanager_license_configuration": licensemanager.ResourceLicenseConfiguration(), "aws_lightsail_bucket": lightsail.ResourceBucket(), + "aws_lightsail_bucket_access_key": lightsail.ResourceBucketAccessKey(), "aws_lightsail_certificate": lightsail.ResourceCertificate(), "aws_lightsail_container_service": lightsail.ResourceContainerService(), "aws_lightsail_container_service_deployment_version": lightsail.ResourceContainerServiceDeploymentVersion(), diff --git a/internal/service/lightsail/bucket_access_key.go b/internal/service/lightsail/bucket_access_key.go new file mode 100644 index 00000000000..d199c8708e0 --- /dev/null +++ b/internal/service/lightsail/bucket_access_key.go @@ -0,0 +1,152 @@ +package lightsail + +import ( + "context" + "errors" + "regexp" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/lightsail" + "github.com/hashicorp/aws-sdk-go-base/v2/awsv1shim/v2/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "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/create" + "github.com/hashicorp/terraform-provider-aws/internal/flex" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + "github.com/hashicorp/terraform-provider-aws/names" +) + +const ( + BucketAccessKeyIdPartsCount = 2 +) + +func ResourceBucketAccessKey() *schema.Resource { + return &schema.Resource{ + CreateWithoutTimeout: resourceBucketAccessKeyCreate, + ReadWithoutTimeout: resourceBucketAccessKeyRead, + DeleteWithoutTimeout: resourceBucketAccessKeyDelete, + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Schema: map[string]*schema.Schema{ + "access_key_id": { + Type: schema.TypeString, + Computed: true, + }, + "bucket_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringMatch(regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,52}[a-z0-9]$`), "Invalid Bucket name. Must match regex: ^[a-z0-9][a-z0-9-]{1,52}[a-z0-9]$"), + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + }, + "secret_access_key": { + Type: schema.TypeString, + Computed: true, + }, + "status": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func resourceBucketAccessKeyCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).LightsailConn() + + in := lightsail.CreateBucketAccessKeyInput{ + BucketName: aws.String(d.Get("bucket_name").(string)), + } + + out, err := conn.CreateBucketAccessKeyWithContext(ctx, &in) + + if err != nil { + return create.DiagError(names.Lightsail, lightsail.OperationTypeCreateBucketAccessKey, ResBucketAccessKey, d.Get("bucket_name").(string), err) + } + + if len(out.Operations) == 0 { + return create.DiagError(names.Lightsail, lightsail.OperationTypeCreateBucketAccessKey, ResBucketAccessKey, d.Get("bucket_name").(string), errors.New("No operations found for request")) + } + + op := out.Operations[0] + + err = waitOperation(conn, op.Id) + if err != nil { + return create.DiagError(names.Lightsail, lightsail.OperationTypeCreateBucketAccessKey, ResBucketAccessKey, d.Get("bucket_name").(string), errors.New("Error waiting for request operation")) + } + + idParts := []string{d.Get("bucket_name").(string), *out.AccessKey.AccessKeyId} + id, err := flex.FlattenResourceId(idParts, BucketAccessKeyIdPartsCount) + + if err != nil { + return create.DiagError(names.Lightsail, create.ErrActionFlatteningResourceId, ResBucketAccessKey, d.Get("bucket_name").(string), err) + } + + d.SetId(id) + d.Set("secret_access_key", out.AccessKey.SecretAccessKey) + + return resourceBucketAccessKeyRead(ctx, d, meta) +} + +func resourceBucketAccessKeyRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).LightsailConn() + + out, err := FindBucketAccessKeyById(ctx, conn, d.Id()) + + if !d.IsNewResource() && tfresource.NotFound(err) { + create.LogNotFoundRemoveState(names.Lightsail, create.ErrActionReading, ResBucketAccessKey, d.Id()) + d.SetId("") + return nil + } + + if err != nil { + return create.DiagError(names.Lightsail, create.ErrActionReading, ResBucketAccessKey, d.Id(), err) + } + + d.Set("access_key_id", out.AccessKeyId) + d.Set("bucket_name", d.Get("bucket_name").(string)) + d.Set("created_at", out.CreatedAt.Format(time.RFC3339)) + d.Set("status", out.Status) + + return nil +} + +func resourceBucketAccessKeyDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).LightsailConn() + parts, err := flex.ExpandResourceId(d.Id(), BucketAccessKeyIdPartsCount) + + if err != nil { + return create.DiagError(names.Lightsail, create.ErrActionExpandingResourceId, ResBucketAccessKey, d.Id(), err) + } + + out, err := conn.DeleteBucketAccessKeyWithContext(ctx, &lightsail.DeleteBucketAccessKeyInput{ + BucketName: aws.String(parts[0]), + AccessKeyId: aws.String(parts[1]), + }) + + if err != nil && tfawserr.ErrCodeEquals(err, lightsail.ErrCodeNotFoundException) { + return nil + } + + if err != nil { + return create.DiagError(names.Lightsail, create.ErrActionDeleting, ResBucketAccessKey, d.Id(), err) + } + + op := out.Operations[0] + + err = waitOperation(conn, op.Id) + + if err != nil { + return create.DiagError(names.Lightsail, lightsail.OperationTypeDeleteCertificate, ResBucketAccessKey, d.Id(), err) + } + + return nil +} diff --git a/internal/service/lightsail/bucket_access_key_test.go b/internal/service/lightsail/bucket_access_key_test.go new file mode 100644 index 00000000000..e31ac4f0e6d --- /dev/null +++ b/internal/service/lightsail/bucket_access_key_test.go @@ -0,0 +1,150 @@ +package lightsail_test + +import ( + "context" + "errors" + "fmt" + "regexp" + "testing" + + "github.com/aws/aws-sdk-go/service/lightsail" + 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" + "github.com/hashicorp/terraform-provider-aws/internal/create" + tflightsail "github.com/hashicorp/terraform-provider-aws/internal/service/lightsail" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + "github.com/hashicorp/terraform-provider-aws/names" +) + +func TestAccLightsailBucketAccessKey_basic(t *testing.T) { + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_lightsail_bucket_access_key.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + acctest.PreCheckPartitionHasService(lightsail.EndpointsID, t) + testAccPreCheck(t) + }, + ErrorCheck: acctest.ErrorCheck(t, lightsail.EndpointsID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckBucketAccessKeyDestroy, + Steps: []resource.TestStep{ + { + Config: testAccBucketAccessKeyConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckBucketAccessKeyExists(resourceName), + resource.TestMatchResourceAttr(resourceName, "access_key_id", regexp.MustCompile(`((?:ASIA|AKIA|AROA|AIDA)([A-Z0-7]{16}))`)), + resource.TestCheckResourceAttrSet(resourceName, "created_at"), + resource.TestMatchResourceAttr(resourceName, "secret_access_key", regexp.MustCompile(`([a-zA-Z0-9+/]{40})`)), + resource.TestCheckResourceAttrSet(resourceName, "status"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"secret_access_key", "bucket_name"}, + }, + }, + }) +} + +func TestAccLightsailBucketAccessKey_disappears(t *testing.T) { + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_lightsail_bucket_access_key.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + acctest.PreCheckPartitionHasService(lightsail.EndpointsID, t) + testAccPreCheck(t) + }, + ErrorCheck: acctest.ErrorCheck(t, lightsail.EndpointsID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckBucketAccessKeyDestroy, + Steps: []resource.TestStep{ + { + Config: testAccBucketAccessKeyConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckBucketAccessKeyExists(resourceName), + acctest.CheckResourceDisappears(acctest.Provider, tflightsail.ResourceBucketAccessKey(), resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func testAccCheckBucketAccessKeyExists(resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("Resource not found: %s", resourceName) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("Resource (%s) ID not set", resourceName) + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).LightsailConn() + + out, err := tflightsail.FindBucketAccessKeyById(context.Background(), conn, rs.Primary.ID) + + if err != nil { + return err + } + + if out == nil { + return fmt.Errorf("BucketAccessKey %q does not exist", rs.Primary.ID) + } + + return nil + } +} + +func testAccCheckBucketAccessKeyDestroy(s *terraform.State) error { + conn := acctest.Provider.Meta().(*conns.AWSClient).LightsailConn() + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_lightsail_bucket_access_key" { + continue + } + + _, err := tflightsail.FindBucketAccessKeyById(context.Background(), conn, rs.Primary.ID) + + if tfresource.NotFound(err) { + continue + } + + if err != nil { + return err + } + + return create.Error(names.Lightsail, create.ErrActionCheckingDestroyed, tflightsail.ResBucketAccessKey, rs.Primary.ID, errors.New("still exists")) + } + + return nil +} + +func testAccBucketAccessKeyConfigBase(rName string) string { + return fmt.Sprintf(` +resource "aws_lightsail_bucket" "test" { + name = %[1]q + bundle_id = "small_1_0" +} +`, rName) +} + +func testAccBucketAccessKeyConfig_basic(rName string) string { + return acctest.ConfigCompose( + testAccBucketAccessKeyConfigBase(rName), ` +resource "aws_lightsail_bucket_access_key" "test" { + bucket_name = aws_lightsail_bucket.test.id +} +`, + ) +} diff --git a/internal/service/lightsail/consts.go b/internal/service/lightsail/consts.go index 219ffa74fb9..ffea8ee4bd1 100644 --- a/internal/service/lightsail/consts.go +++ b/internal/service/lightsail/consts.go @@ -2,6 +2,7 @@ package lightsail const ( ResBucket = "Bucket" + ResBucketAccessKey = "Bucket Access Key" ResCertificate = "Certificate" ResDatabase = "Database" ResDisk = "Disk" diff --git a/internal/service/lightsail/find.go b/internal/service/lightsail/find.go index 4171af681cd..083171375c9 100644 --- a/internal/service/lightsail/find.go +++ b/internal/service/lightsail/find.go @@ -9,6 +9,7 @@ import ( "github.com/aws/aws-sdk-go/service/lightsail" "github.com/hashicorp/aws-sdk-go-base/v2/awsv1shim/v2/tfawserr" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-provider-aws/internal/flex" "github.com/hashicorp/terraform-provider-aws/internal/tfresource" ) @@ -452,3 +453,42 @@ func FindInstanceById(ctx context.Context, conn *lightsail.Lightsail, id string) return out.Instance, nil } + +func FindBucketAccessKeyById(ctx context.Context, conn *lightsail.Lightsail, id string) (*lightsail.AccessKey, error) { + parts, err := flex.ExpandResourceId(id, BucketAccessKeyIdPartsCount) + + if err != nil { + return nil, err + } + + in := &lightsail.GetBucketAccessKeysInput{BucketName: aws.String(parts[0])} + out, err := conn.GetBucketAccessKeysWithContext(ctx, in) + + if tfawserr.ErrCodeEquals(err, lightsail.ErrCodeNotFoundException) { + return nil, &resource.NotFoundError{ + LastError: err, + LastRequest: in, + } + } + + if err != nil { + return nil, err + } + + var entry *lightsail.AccessKey + entryExists := false + + for _, n := range out.AccessKeys { + if parts[1] == aws.StringValue(n.AccessKeyId) { + entry = n + entryExists = true + break + } + } + + if !entryExists { + return nil, tfresource.NewEmptyResultError(in) + } + + return entry, nil +} diff --git a/website/docs/r/lightsail_bucket_access_key.html.markdown b/website/docs/r/lightsail_bucket_access_key.html.markdown new file mode 100644 index 00000000000..8aed1f5b585 --- /dev/null +++ b/website/docs/r/lightsail_bucket_access_key.html.markdown @@ -0,0 +1,48 @@ +--- +subcategory: "Lightsail" +layout: "aws" +page_title: "AWS: aws_lightsail_bucket_access_key" +description: |- + Provides a lightsail bucket access key. This is a set of credentials that allow API requests to be made to the lightsail bucket. +--- + +# Resource: aws_lightsail_bucket_access_key + +Provides a lightsail bucket access key. This is a set of credentials that allow API requests to be made to the lightsail bucket. + +## Example Usage + +```terraform +resource "aws_lightsail_bucket" "test" { + name = "mytestbucket" + bundle_id = "small_1_0" +} + +resource "aws_lightsail_bucket_access_key_access_key" "test" { + bucket_name = aws_lightsail_bucket_access_key.test.id +} +``` + +## Argument Reference + +The following arguments are supported: + +* `bucket_name` - (Required) The name of the bucket that the new access key will belong to, and grant access to. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `id` - A combination of attributes separated by a `,` to create a unique id: `bucket_name`,`access_key_id` +* `access_key_id` - The ID of the access key. +* `created_at` - The timestamp when the access key was created. +* `secret_access_key` - The secret access key used to sign requests. This attribute is not available for imported resources. Note that this will be written to the state file. +* `status` - The status of the access key. + +## Import + +`aws_lightsail_bucket_access_key` can be imported by using the `id` attribute, e.g., + +``` +$ terraform import aws_lightsail_bucket_access_key.test example-bucket,AKIA47VOQ2KPR7LLRZ6D +```