diff --git a/.changelog/44621.txt b/.changelog/44621.txt new file mode 100644 index 000000000000..bc2acb0416b6 --- /dev/null +++ b/.changelog/44621.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +data-source/aws_ecr_images: Add `tag_status`, `max_results`, and `describe_images` arguments with `image_details` attribute +``` diff --git a/internal/service/ecr/images_data_source.go b/internal/service/ecr/images_data_source.go index afa3cc9cb6f2..78cd75d3b72c 100644 --- a/internal/service/ecr/images_data_source.go +++ b/internal/service/ecr/images_data_source.go @@ -31,7 +31,16 @@ type imagesDataSource struct { func (d *imagesDataSource) Schema(ctx context.Context, request datasource.SchemaRequest, response *datasource.SchemaResponse) { response.Schema = schema.Schema{ Attributes: map[string]schema.Attribute{ - "image_ids": framework.DataSourceComputedListOfObjectAttribute[imagesIDsModel](ctx), + "describe_images": schema.BoolAttribute{ + Optional: true, + Description: "Whether to call DescribeImages API to get detailed image information", + }, + "image_details": framework.DataSourceComputedListOfObjectAttribute[imageDetailsModel](ctx), + "image_ids": framework.DataSourceComputedListOfObjectAttribute[imagesIDsModel](ctx), + "max_results": schema.Int64Attribute{ + Optional: true, + Description: "Maximum number of images to return", + }, "registry_id": schema.StringAttribute{ Optional: true, Description: "ID of the registry (AWS account ID)", @@ -40,6 +49,10 @@ func (d *imagesDataSource) Schema(ctx context.Context, request datasource.Schema Required: true, Description: "Name of the repository", }, + "tag_status": schema.StringAttribute{ + Optional: true, + Description: "Filter images by tag status. Valid values: TAGGED, UNTAGGED, ANY", + }, }, } } @@ -59,6 +72,20 @@ func (d *imagesDataSource) Read(ctx context.Context, req datasource.ReadRequest, return } + // Set tag status filter if provided + if !data.TagStatus.IsNull() && !data.TagStatus.IsUnknown() { + tagStatus := awstypes.TagStatus(data.TagStatus.ValueString()) + input.Filter = &awstypes.ListImagesFilter{ + TagStatus: tagStatus, + } + } + + // Set max results if provided + if !data.MaxResults.IsNull() && !data.MaxResults.IsUnknown() { + maxResults := int32(data.MaxResults.ValueInt64()) + input.MaxResults = &maxResults + } + output, err := findImages(ctx, conn, &input) if err != nil { resp.Diagnostics.AddError("reading ECR Images", err.Error()) @@ -70,9 +97,55 @@ func (d *imagesDataSource) Read(ctx context.Context, req datasource.ReadRequest, return } + // If describe_images is true, call DescribeImages API + if !data.DescribeImages.IsNull() && data.DescribeImages.ValueBool() { + registryID := "" + if !data.RegistryID.IsNull() { + registryID = data.RegistryID.ValueString() + } + + imageDetails, err := findImagesDetails(ctx, conn, data.RepositoryName.ValueString(), registryID, output) + if err != nil { + resp.Diagnostics.AddError("describing ECR Images", err.Error()) + return + } + + resp.Diagnostics.Append(fwflex.Flatten(ctx, imageDetails, &data.ImageDetails)...) + if resp.Diagnostics.HasError() { + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } +func findImagesDetails(ctx context.Context, conn *ecr.Client, repositoryName, registryID string, imageIds []awstypes.ImageIdentifier) ([]awstypes.ImageDetail, error) { + var output []awstypes.ImageDetail + + // DescribeImages has a limit of 100 images per request + const batchSize = 100 + for i := 0; i < len(imageIds); i += batchSize { + end := min(i+batchSize, len(imageIds)) + + input := &ecr.DescribeImagesInput{ + RepositoryName: &repositoryName, + ImageIds: imageIds[i:end], + } + if registryID != "" { + input.RegistryId = ®istryID + } + + result, err := conn.DescribeImages(ctx, input) + if err != nil { + return nil, err + } + + output = append(output, result.ImageDetails...) + } + + return output, nil +} + func findImages(ctx context.Context, conn *ecr.Client, input *ecr.ListImagesInput) ([]awstypes.ImageIdentifier, error) { var output []awstypes.ImageIdentifier @@ -99,9 +172,22 @@ func findImages(ctx context.Context, conn *ecr.Client, input *ecr.ListImagesInpu type imagesDataSourceModel struct { framework.WithRegionModel - ImageIDs fwtypes.ListNestedObjectValueOf[imagesIDsModel] `tfsdk:"image_ids"` - RegistryID types.String `tfsdk:"registry_id"` - RepositoryName types.String `tfsdk:"repository_name"` + DescribeImages types.Bool `tfsdk:"describe_images"` + ImageDetails fwtypes.ListNestedObjectValueOf[imageDetailsModel] `tfsdk:"image_details"` + ImageIDs fwtypes.ListNestedObjectValueOf[imagesIDsModel] `tfsdk:"image_ids"` + MaxResults types.Int64 `tfsdk:"max_results"` + RegistryID types.String `tfsdk:"registry_id"` + RepositoryName types.String `tfsdk:"repository_name"` + TagStatus types.String `tfsdk:"tag_status"` +} + +type imageDetailsModel struct { + ImageDigest types.String `tfsdk:"image_digest"` + ImagePushedAt types.String `tfsdk:"image_pushed_at"` + ImageSizeInBytes types.Int64 `tfsdk:"image_size_in_bytes"` + ImageTags fwtypes.ListValueOf[types.String] `tfsdk:"image_tags"` + RegistryID types.String `tfsdk:"registry_id"` + RepositoryName types.String `tfsdk:"repository_name"` } type imagesIDsModel struct { diff --git a/internal/service/ecr/images_data_source_test.go b/internal/service/ecr/images_data_source_test.go index d97f2cd2efbb..037fea6006d4 100644 --- a/internal/service/ecr/images_data_source_test.go +++ b/internal/service/ecr/images_data_source_test.go @@ -5,10 +5,12 @@ package ecr_test import ( "fmt" + "strconv" "testing" sdkacctest "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/hashicorp/terraform-provider-aws/internal/acctest" "github.com/hashicorp/terraform-provider-aws/names" ) @@ -102,6 +104,141 @@ data "aws_ecr_images" "test" { `, rName) } +func TestAccECRImagesDataSource_describeImages(t *testing.T) { + ctx := acctest.Context(t) + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + dataSourceName := "data.aws_ecr_images.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.ECRServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccImagesDataSourceConfig_describeImages(rName, true), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(dataSourceName, names.AttrRepositoryName, rName), + resource.TestCheckResourceAttr(dataSourceName, "describe_images", acctest.CtTrue), + ), + }, + }, + }) +} + +func TestAccECRImagesDataSource_maxResults(t *testing.T) { + ctx := acctest.Context(t) + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + dataSourceName := "data.aws_ecr_images.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.ECRServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccImagesDataSourceConfig_maxResults(rName, 5), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(dataSourceName, names.AttrRepositoryName, rName), + resource.TestCheckResourceAttr(dataSourceName, "max_results", "5"), + ), + }, + }, + }) +} + +func TestAccECRImagesDataSource_tagStatus(t *testing.T) { + ctx := acctest.Context(t) + registryID := "137112412989" + repositoryName := "amazonlinux" + dataSourceName := "data.aws_ecr_images.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.ECRServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccImagesDataSourceConfig_tagStatusPublic(registryID, repositoryName, "TAGGED"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(dataSourceName, names.AttrRepositoryName, repositoryName), + resource.TestCheckResourceAttr(dataSourceName, "tag_status", "TAGGED"), + resource.TestCheckResourceAttrSet(dataSourceName, "image_ids.#"), + // Verify all returned images have tags + testAccCheckImagesAllHaveTags(dataSourceName), + ), + }, + { + Config: testAccImagesDataSourceConfig_tagStatusPublic(registryID, repositoryName, "ANY"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(dataSourceName, names.AttrRepositoryName, repositoryName), + resource.TestCheckResourceAttr(dataSourceName, "tag_status", "ANY"), + resource.TestCheckResourceAttrSet(dataSourceName, "image_ids.#"), + ), + }, + }, + }) +} + +func testAccImagesDataSourceConfig_describeImages(rName string, describeImages bool) string { + return fmt.Sprintf(` +resource "aws_ecr_repository" "test" { + name = %[1]q +} + +data "aws_ecr_images" "test" { + repository_name = aws_ecr_repository.test.name + describe_images = %[2]t +} +`, rName, describeImages) +} + +func testAccImagesDataSourceConfig_maxResults(rName string, maxResults int) string { + return fmt.Sprintf(` +resource "aws_ecr_repository" "test" { + name = %[1]q +} + +data "aws_ecr_images" "test" { + repository_name = aws_ecr_repository.test.name + max_results = %[2]d +} +`, rName, maxResults) +} + +func testAccCheckImagesAllHaveTags(resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("Not found: %s", resourceName) + } + + imageCount := rs.Primary.Attributes["image_ids.#"] + count, err := strconv.Atoi(imageCount) + if err != nil { + return err + } + + for i := range count { + tagKey := fmt.Sprintf("image_ids.%d.image_tag", i) + if tag := rs.Primary.Attributes[tagKey]; tag == "" { + return fmt.Errorf("Image at index %d has no tag when TAGGED filter was used", i) + } + } + + return nil + } +} + +func testAccImagesDataSourceConfig_tagStatusPublic(registryID, repositoryName, tagStatus string) string { + return fmt.Sprintf(` +data "aws_ecr_images" "test" { + registry_id = %[1]q + repository_name = %[2]q + tag_status = %[3]q +} +`, registryID, repositoryName, tagStatus) +} + func testAccImagesDataSourceConfig_registryID(registryID, repositoryName string) string { return fmt.Sprintf(` data "aws_ecr_images" "test" { diff --git a/website/docs/d/ecr_images.html.markdown b/website/docs/d/ecr_images.html.markdown index d6e2db4ab312..e71efb282d95 100644 --- a/website/docs/d/ecr_images.html.markdown +++ b/website/docs/d/ecr_images.html.markdown @@ -26,6 +26,37 @@ output "image_tags" { } ``` +### Filter by Tag Status + +```terraform +data "aws_ecr_images" "tagged_only" { + repository_name = "my-repository" + tag_status = "TAGGED" +} +``` + +### Limit Results + +```terraform +data "aws_ecr_images" "limited" { + repository_name = "my-repository" + max_results = 10 +} +``` + +### Get Detailed Image Information + +```terraform +data "aws_ecr_images" "detailed" { + repository_name = "my-repository" + describe_images = true +} + +output "image_details" { + value = data.aws_ecr_images.detailed.image_details +} +``` + ## Argument Reference This data source supports the following arguments: @@ -33,6 +64,9 @@ This data source 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). * `registry_id` - (Optional) ID of the Registry where the repository resides. * `repository_name` - (Required) Name of the ECR Repository. +* `tag_status` - (Optional) Filter images by tag status. Valid values: `TAGGED`, `UNTAGGED`, `ANY`. +* `max_results` - (Optional) Maximum number of images to return. +* `describe_images` - (Optional) Whether to call DescribeImages API to get detailed image information. Defaults to `false`. ## Attribute Reference @@ -41,3 +75,10 @@ This data source exports the following attributes in addition to the arguments a * `image_ids` - List of image objects containing image digest and tags. Each object has the following attributes: * `image_digest` - The sha256 digest of the image manifest. * `image_tag` - The tag associated with the image. +* `image_details` - List of detailed image information (only populated when `describe_images` is `true`). Each object has the following attributes: + * `image_digest` - The sha256 digest of the image manifest. + * `image_pushed_at` - The date and time when the image was pushed to the repository. + * `image_size_in_bytes` - The size of the image in bytes. + * `image_tags` - List of tags associated with the image. + * `registry_id` - The AWS account ID associated with the registry. + * `repository_name` - The name of the repository.