From fb66043c9e31810a6aac7aa71dacadaf717e9eeb Mon Sep 17 00:00:00 2001 From: Modular Magician Date: Fri, 14 Jun 2024 16:06:41 +0000 Subject: [PATCH] Add artifact docker image data source (#9521) [upstream:9156b0c736912c251b4b79f853b7436a6cba8d69] Signed-off-by: Modular Magician --- .changelog/9521.txt | 3 + google/provider/provider_mmv1_resources.go | 1 + ...a_source_artifact_registry_docker_image.go | 322 ++++++++++++++++++ ...rce_artifact_registry_docker_image_test.go | 151 ++++++++ ...tifact_registry_docker_image.html.markdown | 76 +++++ 5 files changed, 553 insertions(+) create mode 100644 .changelog/9521.txt create mode 100644 google/services/artifactregistry/data_source_artifact_registry_docker_image.go create mode 100644 google/services/artifactregistry/data_source_artifact_registry_docker_image_test.go create mode 100644 website/docs/d/artifact_registry_docker_image.html.markdown diff --git a/.changelog/9521.txt b/.changelog/9521.txt new file mode 100644 index 00000000000..ac18c5895ba --- /dev/null +++ b/.changelog/9521.txt @@ -0,0 +1,3 @@ +```release-note:new-datasource +`google_artifact_registry_docker_image` +``` \ No newline at end of file diff --git a/google/provider/provider_mmv1_resources.go b/google/provider/provider_mmv1_resources.go index 8654c64d0b0..04cfa88bb81 100644 --- a/google/provider/provider_mmv1_resources.go +++ b/google/provider/provider_mmv1_resources.go @@ -139,6 +139,7 @@ var handwrittenDatasources = map[string]*schema.Resource{ "google_active_folder": resourcemanager.DataSourceGoogleActiveFolder(), "google_alloydb_locations": alloydb.DataSourceAlloydbLocations(), "google_alloydb_supported_database_flags": alloydb.DataSourceAlloydbSupportedDatabaseFlags(), + "google_artifact_registry_docker_image": artifactregistry.DataSourceArtifactRegistryDockerImage(), "google_artifact_registry_repository": artifactregistry.DataSourceArtifactRegistryRepository(), "google_apphub_discovered_workload": apphub.DataSourceApphubDiscoveredWorkload(), "google_app_engine_default_service_account": appengine.DataSourceGoogleAppEngineDefaultServiceAccount(), diff --git a/google/services/artifactregistry/data_source_artifact_registry_docker_image.go b/google/services/artifactregistry/data_source_artifact_registry_docker_image.go new file mode 100644 index 00000000000..f6eea2eb1f8 --- /dev/null +++ b/google/services/artifactregistry/data_source_artifact_registry_docker_image.go @@ -0,0 +1,322 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 +package artifactregistry + +import ( + "fmt" + "net/url" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-provider-google/google/tpgresource" + transport_tpg "github.com/hashicorp/terraform-provider-google/google/transport" +) + +// https://cloud.google.com/artifact-registry/docs/reference/rest/v1/projects.locations.repositories.dockerImages#DockerImage +type DockerImage struct { + name string + uri string + tags []string + imageSizeBytes string + mediaType string + uploadTime string + buildTime string + updateTime string +} + +func DataSourceArtifactRegistryDockerImage() *schema.Resource { + + return &schema.Resource{ + Read: DataSourceArtifactRegistryDockerImageRead, + + Schema: map[string]*schema.Schema{ + "project": { + Type: schema.TypeString, + Optional: true, + Description: `Project ID of the project.`, + }, + "location": { + Type: schema.TypeString, + Required: true, + Description: `The region of the artifact registry repository. For example, "us-west1".`, + }, + "repository_id": { + Type: schema.TypeString, + Required: true, + Description: `The last part of the repository name to fetch from.`, + }, + "image_name": { + Type: schema.TypeString, + Required: true, + Description: `The image name to fetch.`, + }, + "name": { + Type: schema.TypeString, + Computed: true, + Description: `The fully qualified name of the fetched image.`, + }, + "self_link": { + Type: schema.TypeString, + Computed: true, + Description: `The URI to access the image.`, + }, + "tags": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: `All tags associated with the image.`, + }, + "image_size_bytes": { + Type: schema.TypeString, + Computed: true, + Description: `Calculated size of the image in bytes.`, + }, + "media_type": { + Type: schema.TypeString, + Computed: true, + Description: `Media type of this image.`, + }, + "upload_time": { + Type: schema.TypeString, + Computed: true, + Description: `The time, as a RFC 3339 string, the image was uploaded.`, + }, + "build_time": { + Type: schema.TypeString, + Computed: true, + Description: `The time, as a RFC 3339 string, this image was built.`, + }, + "update_time": { + Type: schema.TypeString, + Computed: true, + Description: `The time, as a RFC 3339 string, this image was updated.`, + }, + }, + } +} + +func DataSourceArtifactRegistryDockerImageRead(d *schema.ResourceData, meta interface{}) error { + config := meta.(*transport_tpg.Config) + userAgent, err := tpgresource.GenerateUserAgentString(d, config.UserAgent) + if err != nil { + return err + } + + var res DockerImage + + imageName, tag, digest := parseImage(d.Get("image_name").(string)) + + if digest != "" { + // fetch image by digest + // https://cloud.google.com/artifact-registry/docs/reference/rest/v1/projects.locations.repositories.dockerImages/get + imageUrlSafe := url.QueryEscape(imageName) + urlRequest, err := tpgresource.ReplaceVars(d, config, fmt.Sprintf("{{ArtifactRegistryBasePath}}projects/{{project}}/locations/{{location}}/repositories/{{repository_id}}/dockerImages/%s@%s", imageUrlSafe, digest)) + if err != nil { + return fmt.Errorf("Error setting api endpoint") + } + + resGet, err := transport_tpg.SendRequest(transport_tpg.SendRequestOptions{ + Config: config, + Method: "GET", + RawURL: urlRequest, + UserAgent: userAgent, + }) + if err != nil { + return err + } + + res = convertResponseToStruct(resGet) + } else { + // fetch the list of images, ordered by update time + // https://cloud.google.com/artifact-registry/docs/reference/rest/v1/projects.locations.repositories.dockerImages/list + urlRequest, err := tpgresource.ReplaceVars(d, config, "{{ArtifactRegistryBasePath}}projects/{{project}}/locations/{{location}}/repositories/{{repository_id}}/dockerImages") + if err != nil { + return fmt.Errorf("Error setting api endpoint") + } + + urlRequest, err = transport_tpg.AddQueryParams(urlRequest, map[string]string{"orderBy": "update_time desc"}) + if err != nil { + return err + } + + res, err = retrieveAndFilterImages(d, config, urlRequest, userAgent, imageName, tag) + if err != nil { + return err + } + } + + // set the schema data using the response + if err := d.Set("name", res.name); err != nil { + return fmt.Errorf("Error setting name: %s", err) + } + + if err := d.Set("self_link", res.uri); err != nil { + return fmt.Errorf("Error setting self_link: %s", err) + } + + if err := d.Set("tags", res.tags); err != nil { + return fmt.Errorf("Error setting tags: %s", err) + } + + if err := d.Set("image_size_bytes", res.imageSizeBytes); err != nil { + return fmt.Errorf("Error setting image_size_bytes: %s", err) + } + + if err := d.Set("media_type", res.mediaType); err != nil { + return fmt.Errorf("Error setting media_type: %s", err) + } + + if err := d.Set("upload_time", res.uploadTime); err != nil { + return fmt.Errorf("Error setting upload_time: %s", err) + } + + if err := d.Set("build_time", res.buildTime); err != nil { + return fmt.Errorf("Error setting build_time: %s", err) + } + + if err := d.Set("update_time", res.updateTime); err != nil { + return fmt.Errorf("Error setting update_time: %s", err) + } + + id, err := tpgresource.ReplaceVars(d, config, "{{ArtifactRegistryBasePath}}projects/{{project}}/locations/{{location}}/repositories/{{repository_id}}/dockerImages/{{image_name}}") + if err != nil { + return fmt.Errorf("Error constructing the data source id: %s", err) + } + + d.SetId(id) + + return nil +} + +func parseImage(image string) (imageName string, tag string, digest string) { + splitByAt := strings.Split(image, "@") + splitByColon := strings.Split(image, ":") + + if len(splitByAt) == 2 { + imageName = splitByAt[0] + digest = splitByAt[1] + } else if len(splitByColon) == 2 { + imageName = splitByColon[0] + tag = splitByColon[1] + } else { + imageName = image + } + + return imageName, tag, digest +} + +func retrieveAndFilterImages(d *schema.ResourceData, config *transport_tpg.Config, urlRequest string, userAgent string, imageName string, tag string) (DockerImage, error) { + // Paging through the list method until either: + // if a tag was provided, the matching image name and tag pair + // otherwise, return the first matching image name + + for { + resListImages, token, err := retrieveListOfDockerImages(config, urlRequest, userAgent) + if err != nil { + return DockerImage{}, err + } + + var resFiltered []DockerImage + for _, image := range resListImages { + if strings.Contains(image.name, "/"+url.QueryEscape(imageName)+"@") { + resFiltered = append(resFiltered, image) + } + } + + if tag != "" { + for _, image := range resFiltered { + for _, iterTag := range image.tags { + if iterTag == tag { + return image, nil + } + } + } + } else if len(resFiltered) > 0 { + return resFiltered[0], nil + } + + if token == "" { + return DockerImage{}, fmt.Errorf("Requested image was not found.") + } + + urlRequest, err = transport_tpg.AddQueryParams(urlRequest, map[string]string{"pageToken": token}) + if err != nil { + return DockerImage{}, err + } + } +} + +func retrieveListOfDockerImages(config *transport_tpg.Config, urlRequest string, userAgent string) ([]DockerImage, string, error) { + resList, err := transport_tpg.SendRequest(transport_tpg.SendRequestOptions{ + Config: config, + Method: "GET", + RawURL: urlRequest, + UserAgent: userAgent, + }) + if err != nil { + return make([]DockerImage, 0), "", err + } + + if nextPageToken, ok := resList["nextPageToken"].(string); ok { + return flattenDataSourceListResponse(resList), nextPageToken, nil + } else { + return flattenDataSourceListResponse(resList), "", nil + } +} + +func flattenDataSourceListResponse(res map[string]interface{}) []DockerImage { + var dockerImages []DockerImage + + resDockerImages, _ := res["dockerImages"].([]interface{}) + + for _, resImage := range resDockerImages { + image, _ := resImage.(map[string]interface{}) + dockerImages = append(dockerImages, convertResponseToStruct(image)) + } + + return dockerImages +} + +func convertResponseToStruct(res map[string]interface{}) DockerImage { + var dockerImage DockerImage + + if name, ok := res["name"].(string); ok { + dockerImage.name = name + } + + if uri, ok := res["uri"].(string); ok { + dockerImage.uri = uri + } + + if tags, ok := res["tags"].([]interface{}); ok { + var stringTags []string + + for _, tag := range tags { + strTag := tag.(string) + stringTags = append(stringTags, strTag) + } + dockerImage.tags = stringTags + } + + if imageSizeBytes, ok := res["imageSizeBytes"].(string); ok { + dockerImage.imageSizeBytes = imageSizeBytes + } + + if mediaType, ok := res["mediaType"].(string); ok { + dockerImage.mediaType = mediaType + } + + if uploadTime, ok := res["uploadTime"].(string); ok { + dockerImage.uploadTime = uploadTime + } + + if buildTime, ok := res["buildTime"].(string); ok { + dockerImage.buildTime = buildTime + } + + if updateTime, ok := res["updateTime"].(string); ok { + dockerImage.updateTime = updateTime + } + + return dockerImage +} diff --git a/google/services/artifactregistry/data_source_artifact_registry_docker_image_test.go b/google/services/artifactregistry/data_source_artifact_registry_docker_image_test.go new file mode 100644 index 00000000000..a442386160c --- /dev/null +++ b/google/services/artifactregistry/data_source_artifact_registry_docker_image_test.go @@ -0,0 +1,151 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 +package artifactregistry_test + +import ( + "fmt" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/hashicorp/terraform-provider-google/google/acctest" +) + +func TestAccDataSourceArtifactRegistryDockerImage(t *testing.T) { + t.Parallel() + + resourceName := "data.google_artifact_registry_docker_image.test" + + acctest.VcrTest(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + Steps: []resource.TestStep{ + { + Config: testAccDataSourceArtifactRegistryDockerImageConfig, + Check: resource.ComposeTestCheckFunc( + // Data source using a tag + checkTaggedDataSources(resourceName+"Tag", "latest"), + resource.TestCheckResourceAttrSet(resourceName+"Tag", "image_size_bytes"), + validateTimeStamps(resourceName+"Tag"), + + // Data source using a digest + checkDigestDataSources( + resourceName+"Digest", + "projects/cloudrun/locations/us/repositories/container/dockerImages/hello@sha256:7a6e0dfb0142464ce0ba14a2cfcac75e383e36f39f47539c870132c826314ad6", + "us-docker.pkg.dev/cloudrun/container/hello@sha256:7a6e0dfb0142464ce0ba14a2cfcac75e383e36f39f47539c870132c826314ad6", + ), + resource.TestCheckResourceAttrSet(resourceName+"Digest", "image_size_bytes"), + validateTimeStamps(resourceName+"Digest"), + + // url safe docker name using a tag + checkTaggedDataSources(resourceName+"UrlTag", "latest"), + + // url safe docker name using a digest + checkDigestDataSources( + resourceName+"UrlDigest", + "projects/go-containerregistry/locations/us/repositories/gcr.io/dockerImages/krane%2Fdebug@sha256:26903bf659994649af0b8ccb2675b76318b2bc3b2c85feea9a1f9d5b98eff363", + "us-docker.pkg.dev/go-containerregistry/gcr.io/krane/debug@sha256:26903bf659994649af0b8ccb2675b76318b2bc3b2c85feea9a1f9d5b98eff363", + ), + + // Data source using no tag or digest + resource.TestCheckResourceAttrSet(resourceName+"None", "repository_id"), + resource.TestCheckResourceAttrSet(resourceName+"None", "image_name"), + resource.TestCheckResourceAttrSet(resourceName+"None", "name"), + resource.TestCheckResourceAttrSet(resourceName+"None", "self_link"), + ), + }, + }, + }) +} + +// Test the data source against the public AR repos +// https://console.cloud.google.com/artifacts/docker/cloudrun/us/container +// https://console.cloud.google.com/artifacts/docker/go-containerregistry/us/gcr.io +// Currently, gcr.io does not provide a imageSizeBytes or buildTime field in the JSON response +const testAccDataSourceArtifactRegistryDockerImageConfig = ` +data "google_artifact_registry_docker_image" "testTag" { + project = "cloudrun" + location = "us" + repository_id = "container" + image_name = "hello:latest" +} + +data "google_artifact_registry_docker_image" "testDigest" { + project = "cloudrun" + location = "us" + repository_id = "container" + image_name = "hello@sha256:7a6e0dfb0142464ce0ba14a2cfcac75e383e36f39f47539c870132c826314ad6" +} + +data "google_artifact_registry_docker_image" "testUrlTag" { + project = "go-containerregistry" + location = "us" + repository_id = "gcr.io" + image_name = "krane/debug:latest" +} + +data "google_artifact_registry_docker_image" "testUrlDigest" { + project = "go-containerregistry" + location = "us" + repository_id = "gcr.io" + image_name = "krane/debug@sha256:26903bf659994649af0b8ccb2675b76318b2bc3b2c85feea9a1f9d5b98eff363" +} + +data "google_artifact_registry_docker_image" "testNone" { + project = "go-containerregistry" + location = "us" + repository_id = "gcr.io" + image_name = "crane" +} +` + +func checkTaggedDataSources(resourceName string, expectedTag string) resource.TestCheckFunc { + return resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "repository_id"), + resource.TestCheckResourceAttrSet(resourceName, "image_name"), + resource.TestCheckResourceAttrSet(resourceName, "name"), + resource.TestCheckResourceAttrSet(resourceName, "self_link"), + resource.TestCheckTypeSetElemAttr(resourceName, "tags.*", expectedTag), + resource.TestCheckResourceAttrSet(resourceName, "media_type"), + ) +} + +func checkDigestDataSources(resourceName string, expectedName string, expectedSelfLink string) resource.TestCheckFunc { + return resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "repository_id"), + resource.TestCheckResourceAttrSet(resourceName, "image_name"), + resource.TestCheckResourceAttr(resourceName, "name", expectedName), + resource.TestCheckResourceAttr(resourceName, "self_link", expectedSelfLink), + resource.TestCheckResourceAttrSet(resourceName, "media_type"), + ) +} + +func validateTimeStamps(dataSourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + // check that the timestamps are RFC3339 + ds, ok := s.RootModule().Resources[dataSourceName] + if !ok { + return fmt.Errorf("can't find %s in state", dataSourceName) + } + + if !isRFC3339(ds.Primary.Attributes["upload_time"]) { + return fmt.Errorf("upload_time is not RFC3339: %s", ds.Primary.Attributes["upload_time"]) + } + + if !isRFC3339(ds.Primary.Attributes["build_time"]) { + return fmt.Errorf("build_time is not RFC3339: %s", ds.Primary.Attributes["build_time"]) + } + + if !isRFC3339(ds.Primary.Attributes["update_time"]) { + return fmt.Errorf("update_time is not RFC3339: %s", ds.Primary.Attributes["update_time"]) + } + + return nil + } +} + +func isRFC3339(s string) bool { + _, err := time.Parse(time.RFC3339, s) + return err == nil +} diff --git a/website/docs/d/artifact_registry_docker_image.html.markdown b/website/docs/d/artifact_registry_docker_image.html.markdown new file mode 100644 index 00000000000..4065bd03351 --- /dev/null +++ b/website/docs/d/artifact_registry_docker_image.html.markdown @@ -0,0 +1,76 @@ +--- +subcategory: "Artifact Registry" +description: |- + Get information about a Docker Image within a Google Artifact Registry Repository. +--- + +# google\_artifact\_registry\_docker\_image + +This data source fetches information from a provided Artifact Registry repository, including the fully qualified name and URI for an image, based on a the latest version of image name and optional digest or tag. + +~> **Note** +Requires one of the following OAuth scopes: `https://www.googleapis.com/auth/cloud-platform` or `https://www.googleapis.com/auth/cloud-platform.read-only`. + +## Example Usage + +```hcl +resource "google_artifact_registry_repository" "my_repo" { + location = "us-west1" + repository_id = "my-repository" + format = "DOCKER" +} + +data "google_artifact_registry_docker_image" "my_image" { + repository = google_artifact_registry_repository.my_repo.id + image = "my-image" + tag = "my-tag" +} + +resource "google_cloud_run_v2_service" "default" { + # ... + + template { + containers { + image = data.google_artifact_registry_docker_image.my_image.self_link + } + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `location` - (Required) The location of the artifact registry. + +* `repository_id` - (Required) The last part of the repository name. to fetch from. + +* `image_name` - (Required) The image name to fetch. If no digest or tag is provided, then the latest modified image will be used. + +* `project` - (Optional) The project ID in which the resource belongs. If it is not provided, the provider project is used. + +## Attributes Reference + +The following computed attributes are exported: + +* `name` - The fully qualified name of the fetched image. This name has the form: `projects/{{project}}/locations/{{location}}/repository/{{repository_id}}/dockerImages/{{docker_image}}`. For example, +``` +projects/test-project/locations/us-west4/repositories/test-repo/dockerImages/nginx@sha256:e9954c1fc875017be1c3e36eca16be2d9e9bccc4bf072163515467d6a823c7cf +``` + +* `self_link` - The URI to access the image. For example, +``` +us-west4-docker.pkg.dev/test-project/test-repo/nginx@sha256:e9954c1fc875017be1c3e36eca16be2d9e9bccc4bf072163515467d6a823c7cf +``` + +* `tags` - A list of all tags associated with the image. + +* `image_size_bytes` - Calculated size of the image in bytes. + +* `media_type` - Media type of this image, e.g. `application/vnd.docker.distribution.manifest.v2+json`. + +* `upload_time` - The time, as a RFC 3339 string, the image was uploaded. For example, `2014-10-02T15:01:23.045123456Z`. + +* `build_time` - The time, as a RFC 3339 string, this image was built. + +* `update_time` - The time, as a RFC 3339 string, this image was updated.