feat: Implement registry_image_manifests data source (#714)

* feat: Implement registry_image_manifests data source

* fix: Golint errors
This commit is contained in:
Martin 2025-04-29 06:19:37 +02:00 committed by GitHub
parent d638dddb1e
commit 62970c2d5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 466 additions and 150 deletions

View file

@ -0,0 +1,52 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "docker_registry_image_manifests Data Source - terraform-provider-docker"
subcategory: ""
description: |-
Reads the image metadata for each manifest in a Docker multi-arch image from a Docker Registry.
---
# docker_registry_image_manifests (Data Source)
Reads the image metadata for each manifest in a Docker multi-arch image from a Docker Registry.
<!-- schema generated by tfplugindocs -->
## Schema
### Required
- `name` (String) The name of the Docker image, including any tags. e.g. `alpine:latest`
### Optional
- `auth_config` (Block List, Max: 1) Authentication configuration for the Docker registry. It is only used for this resource. (see [below for nested schema](#nestedblock--auth_config))
- `insecure_skip_verify` (Boolean) If `true`, the verification of TLS certificates of the server/registry is disabled. Defaults to `false`
### Read-Only
- `id` (String) The ID of this resource.
- `manifests` (Set of Object) The metadata for each manifest in the image (see [below for nested schema](#nestedatt--manifests))
<a id="nestedblock--auth_config"></a>
### Nested Schema for `auth_config`
Required:
- `address` (String) The address of the Docker registry.
- `password` (String, Sensitive) The password for the Docker registry.
- `username` (String) The username for the Docker registry.
<a id="nestedatt--manifests"></a>
### Nested Schema for `manifests`
Read-Only:
- `architecture` (String)
- `media_type` (String)
- `os` (String)
- `sha256_digest` (String)

View file

@ -2,10 +2,17 @@ package provider
import (
b64 "encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"regexp"
"strings"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
// ECR HTTP authentication needs Bearer token in the Authorization header
@ -72,3 +79,126 @@ func setupHTTPHeadersForRegistryRequests(req *http.Request, fallback bool) {
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v1+prettyjws")
}
}
func setupHTTPRequestForRegistry(method, registry, registryWithProtocol, image, tag, username, password string, fallback bool) (*http.Request, error) {
req, err := http.NewRequest(method, registryWithProtocol+"/v2/"+image+"/manifests/"+tag, nil)
if err != nil {
return nil, fmt.Errorf("Error creating registry request: %s", err)
}
if username != "" {
if registry != "ghcr.io" && !isECRRepositoryURL(registry) && !isAzureCRRepositoryURL(registry) && registry != "gcr.io" {
req.SetBasicAuth(username, password)
} else {
if isECRPublicRepositoryURL(registry) {
password = normalizeECRPasswordForHTTPUsage(password)
req.Header.Add("Authorization", "Bearer "+password)
} else if isECRRepositoryURL(registry) {
password = normalizeECRPasswordForHTTPUsage(password)
req.Header.Add("Authorization", "Basic "+password)
} else {
req.Header.Add("Authorization", "Bearer "+b64.StdEncoding.EncodeToString([]byte(password)))
}
}
}
setupHTTPHeadersForRegistryRequests(req, fallback)
return req, nil
}
// Parses key/value pairs from a WWW-Authenticate header
func parseAuthHeader(header string) (map[string]string, error) {
if !strings.HasPrefix(header, "Bearer") {
return nil, errors.New("missing or invalid www-authenticate header, does not start with 'Bearer'")
}
parts := strings.SplitN(header, " ", 2)
parts = regexp.MustCompile(`\w+\=\".*?\"|\w+[^\s\"]+?`).FindAllString(parts[1], -1) // expression to match auth headers.
opts := make(map[string]string)
for _, part := range parts {
vals := strings.SplitN(part, "=", 2)
key := vals[0]
val := strings.Trim(vals[1], "\", ")
opts[key] = val
}
return opts, nil
}
func getAuthToken(auth map[string]string, username string, password string, client *http.Client) (string, error) {
params := url.Values{}
params.Set("service", auth["service"])
params.Set("scope", auth["scope"])
tokenRequest, err := http.NewRequest("GET", auth["realm"]+"?"+params.Encode(), nil)
if err != nil {
return "", fmt.Errorf("Error creating registry request: %s", err)
}
if username != "" {
tokenRequest.SetBasicAuth(username, password)
}
tokenResponse, err := client.Do(tokenRequest)
if err != nil {
return "", fmt.Errorf("Error during registry request: %s", err)
}
if tokenResponse.StatusCode != http.StatusOK {
return "", fmt.Errorf("Got bad response from registry: " + tokenResponse.Status)
}
body, err := io.ReadAll(tokenResponse.Body)
if err != nil {
return "", fmt.Errorf("Error reading response body: %s", err)
}
token := &TokenResponse{}
err = json.Unmarshal(body, token)
if err != nil {
return "", fmt.Errorf("Error parsing OAuth token response: %s", err)
}
if token.Token != "" {
return token.Token, nil
}
if token.AccessToken != "" {
return token.AccessToken, nil
}
return "", fmt.Errorf("Error unsupported OAuth response")
}
type TokenResponse struct {
Token string
AccessToken string `json:"access_token"`
}
var AuthConfigSchema = &schema.Schema{
Type: schema.TypeList,
Description: "Authentication configuration for the Docker registry. It is only used for this resource.",
Optional: true,
MaxItems: 1,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"address": {
Type: schema.TypeString,
Description: "The address of the Docker registry.",
Required: true,
},
"username": {
Type: schema.TypeString,
Description: "The username for the Docker registry.",
Required: true,
},
"password": {
Type: schema.TypeString,
Description: "The password for the Docker registry.",
Required: true,
Sensitive: true,
},
},
},
}

View file

@ -29,15 +29,26 @@ func TestIsECRRepositoryURL(t *testing.T) {
}
func TestParseAuthHeaders(t *testing.T) {
_, err := parseAuthHeader("")
if err == nil || err.Error() != "missing or invalid www-authenticate header, does not start with 'Bearer'" {
t.Fatalf("wanted \"missing or invalid www-authenticate header, does not start with 'Bearer'\", got nil")
}
header := "Bearer realm=\"https://gcr.io/v2/token\",service=\"gcr.io\",scope=\"repository:<owner>/:<repo>/<name>:pull\""
result := parseAuthHeader(header)
result, err := parseAuthHeader(header)
if err != nil {
t.Errorf("wanted no error, got %s", err)
}
wantScope := "repository:<owner>/:<repo>/<name>:pull"
if result["scope"] != wantScope {
t.Errorf("want: %#v, got: %#v", wantScope, result["scope"])
}
header = "Bearer realm=\"https://gcr.io/v2/token\",service=\"gcr.io\",scope=\"repository:<owner>/:<repo>/<name>:push,pull\""
result = parseAuthHeader(header)
result, err = parseAuthHeader(header)
if err != nil {
t.Errorf("wanted no error, got %s", err)
}
wantScope = "repository:<owner>/:<repo>/<name>:push,pull"
if result["scope"] != wantScope {
t.Errorf("want: %#v, got: %#v", wantScope, result["scope"])

View file

@ -3,14 +3,9 @@ package provider
import (
"context"
"crypto/sha256"
b64 "encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
@ -76,29 +71,11 @@ func dataSourceDockerRegistryImageRead(ctx context.Context, d *schema.ResourceDa
func getImageDigest(registry string, registryWithProtocol string, image, tag, username, password string, insecureSkipVerify, fallback bool) (string, error) {
client := buildHttpClientForRegistry(registryWithProtocol, insecureSkipVerify)
req, err := http.NewRequest("HEAD", registryWithProtocol+"/v2/"+image+"/manifests/"+tag, nil)
req, err := setupHTTPRequestForRegistry("HEAD", registry, registryWithProtocol, image, tag, username, password, fallback)
if err != nil {
return "", fmt.Errorf("Error creating registry request: %s", err)
return "", err
}
if username != "" {
if registry != "ghcr.io" && !isECRRepositoryURL(registry) && !isAzureCRRepositoryURL(registry) && registry != "gcr.io" {
req.SetBasicAuth(username, password)
} else {
if isECRPublicRepositoryURL(registry) {
password = normalizeECRPasswordForHTTPUsage(password)
req.Header.Add("Authorization", "Bearer "+password)
} else if isECRRepositoryURL(registry) {
password = normalizeECRPasswordForHTTPUsage(password)
req.Header.Add("Authorization", "Basic "+password)
} else {
req.Header.Add("Authorization", "Bearer "+b64.StdEncoding.EncodeToString([]byte(password)))
}
}
}
setupHTTPHeadersForRegistryRequests(req, fallback)
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("Error during registry request: %s", err)
@ -111,11 +88,12 @@ func getImageDigest(registry string, registryWithProtocol string, image, tag, us
// Either OAuth is required or the basic auth creds were invalid
case http.StatusUnauthorized:
if !strings.HasPrefix(resp.Header.Get("www-authenticate"), "Bearer") {
return "", fmt.Errorf("Bad credentials: " + resp.Status)
auth, err := parseAuthHeader(resp.Header.Get("www-authenticate"))
if err != nil {
return "", fmt.Errorf("bad credentials: %s", resp.Status)
}
token, err := getAuthToken(resp.Header.Get("www-authenticate"), username, password, client)
token, err := getAuthToken(auth, username, password, client)
if err != nil {
return "", err
}
@ -149,27 +127,6 @@ func getImageDigest(registry string, registryWithProtocol string, image, tag, us
}
}
type TokenResponse struct {
Token string
AccessToken string `json:"access_token"`
}
// Parses key/value pairs from a WWW-Authenticate header
func parseAuthHeader(header string) map[string]string {
parts := strings.SplitN(header, " ", 2)
parts = regexp.MustCompile(`\w+=".*?"|\w+[^\s"]+?`).FindAllString(parts[1], -1) // expression to match auth headers.
opts := make(map[string]string)
for _, part := range parts {
vals := strings.SplitN(part, "=", 2)
key := vals[0]
val := strings.Trim(vals[1], "\", ")
opts[key] = val
}
return opts
}
func getDigestFromResponse(response *http.Response) (string, error) {
header := response.Header.Get("Docker-Content-Digest")
@ -185,51 +142,6 @@ func getDigestFromResponse(response *http.Response) (string, error) {
return header, nil
}
func getAuthToken(authHeader string, username string, password string, client *http.Client) (string, error) {
auth := parseAuthHeader(authHeader)
params := url.Values{}
params.Set("service", auth["service"])
params.Set("scope", auth["scope"])
tokenRequest, err := http.NewRequest("GET", auth["realm"]+"?"+params.Encode(), nil)
if err != nil {
return "", fmt.Errorf("Error creating registry request: %s", err)
}
if username != "" {
tokenRequest.SetBasicAuth(username, password)
}
tokenResponse, err := client.Do(tokenRequest)
if err != nil {
return "", fmt.Errorf("Error during registry request: %s", err)
}
if tokenResponse.StatusCode != http.StatusOK {
return "", fmt.Errorf("Got bad response from registry: " + tokenResponse.Status)
}
body, err := io.ReadAll(tokenResponse.Body)
if err != nil {
return "", fmt.Errorf("Error reading response body: %s", err)
}
token := &TokenResponse{}
err = json.Unmarshal(body, token)
if err != nil {
return "", fmt.Errorf("Error parsing OAuth token response: %s", err)
}
if token.Token != "" {
return token.Token, nil
}
if token.AccessToken != "" {
return token.AccessToken, nil
}
return "", fmt.Errorf("Error unsupported OAuth response")
}
func doDigestRequest(req *http.Request, client *http.Client) (*http.Response, error) {
digestResponse, err := client.Do(req)
if err != nil {

View file

@ -0,0 +1,201 @@
package provider
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"github.com/docker/docker/api/types/registry"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func dataSourceDockerRegistryImageManifests() *schema.Resource {
return &schema.Resource{
Description: "Reads the image metadata for each manifest in a Docker multi-arch image from a Docker Registry.",
ReadContext: dataSourceDockerRegistryImageManifestsRead,
Schema: map[string]*schema.Schema{
"name": {
Type: schema.TypeString,
Description: "The name of the Docker image, including any tags. e.g. `alpine:latest`",
Required: true,
},
"auth_config": AuthConfigSchema,
"manifests": {
Type: schema.TypeSet,
Description: "The metadata for each manifest in the image",
Computed: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"media_type": {
Type: schema.TypeString,
Description: "The media type of the manifest.",
Computed: true,
},
"sha256_digest": {
Type: schema.TypeString,
Description: "The content digest of the manifest, as stored in the registry.",
Computed: true,
},
"architecture": {
Type: schema.TypeString,
Description: "The platform architecture supported by the manifest.",
Computed: true,
},
"os": {
Type: schema.TypeString,
Description: "The operating system supported by the manifest.",
Computed: true,
},
},
},
},
"insecure_skip_verify": {
Type: schema.TypeBool,
Description: "If `true`, the verification of TLS certificates of the server/registry is disabled. Defaults to `false`",
Optional: true,
Default: false,
},
},
}
}
func dataSourceDockerRegistryImageManifestsRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
pullOpts := parseImageOptions(d.Get("name").(string))
var authConfig registry.AuthConfig
if v, ok := d.GetOk("auth_config"); ok {
log.Printf("[INFO] Using auth config from resource: %s", v)
authConfig = buildAuthConfigFromResource(v)
} else {
log.Printf("[INFO] Using auth config from provider: %s", v)
var err error
authConfig, err = getAuthConfigForRegistry(pullOpts.Registry, meta.(*ProviderConfig))
if err != nil {
// The user did not provide a credential for this registry.
// But there are many registries where you can pull without a credential.
// We are setting default values for the authConfig here.
authConfig.Username = ""
authConfig.Password = ""
authConfig.ServerAddress = "https://" + pullOpts.Registry
}
}
insecureSkipVerify := d.Get("insecure_skip_verify").(bool)
manifest, err := getImageManifest(pullOpts.Registry, authConfig.ServerAddress, pullOpts.Repository, pullOpts.Tag, authConfig.Username, authConfig.Password, insecureSkipVerify, false)
if err != nil {
manifest, err = getImageManifest(pullOpts.Registry, authConfig.ServerAddress, pullOpts.Repository, pullOpts.Tag, authConfig.Username, authConfig.Password, insecureSkipVerify, true)
if err != nil {
return diag.Errorf("Got error when attempting to fetch image version %s:%s from registry: %s", pullOpts.Repository, pullOpts.Tag, err)
}
}
d.SetId(fmt.Sprintf("%s:%s", pullOpts.Repository, pullOpts.Tag))
if err = d.Set("manifests", flattenManifests(manifest.Manifests)); err != nil {
log.Printf("[WARN] failed to set manifests from API: %s", err)
}
return nil
}
func getImageManifest(registry, registryWithProtocol, image, tag, username, password string, insecureSkipVerify, fallback bool) (*ManifestResponse, error) {
client := buildHttpClientForRegistry(registryWithProtocol, insecureSkipVerify)
req, err := setupHTTPRequestForRegistry("GET", registry, registryWithProtocol, image, tag, username, password, fallback)
if err != nil {
return nil, err
}
return doManifestRequest(req, client, username, password, true)
}
func doManifestRequest(req *http.Request, client *http.Client, username string, password string, retryUnauthorized bool) (*ManifestResponse, error) {
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("Error during registry request: %s", err)
}
switch resp.StatusCode {
// Basic auth was valid or not needed
case http.StatusOK:
return getManifestsFromResponse(resp)
default:
if resp.StatusCode == http.StatusUnauthorized && retryUnauthorized {
auth, err := parseAuthHeader(resp.Header.Get("www-authenticate"))
if err != nil {
return nil, fmt.Errorf("bad credentials: %s", resp.Status)
}
token, err := getAuthToken(auth, username, password, client)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
return doManifestRequest(req, client, username, password, false)
}
return nil, fmt.Errorf("got bad response from registry: %s", resp.Status)
}
}
func getManifestsFromResponse(response *http.Response) (*ManifestResponse, error) {
body, err := io.ReadAll(response.Body)
if err != nil {
return nil, fmt.Errorf("Error reading response body: %s", err)
}
manifest := &ManifestResponse{}
err = json.Unmarshal(body, manifest)
if err != nil {
return nil, fmt.Errorf("Error parsing manifest response: %s", err)
}
if len(manifest.Manifests) == 0 {
log.Printf("[DEBUG] Manifest response was not for list: %s", string(body))
return nil, fmt.Errorf("Error unsupported manifest response")
}
return manifest, nil
}
func flattenManifests(in []Manifest) []manifestMap {
manifests := make([]manifestMap, len(in))
for i, m := range in {
manifests[i] = manifestMap{
"media_type": m.MediaType,
"sha256_digest": m.Digest,
"architecture": m.Platform.Architecture,
"os": m.Platform.OS,
}
}
return manifests
}
type ManifestResponse struct {
Manifests []Manifest `json:"manifests"`
}
type Manifest struct {
MediaType string `json:"mediaType"`
Digest string `json:"digest"`
Platform ManifestPlatform `json:"platform"`
}
type ManifestPlatform struct {
Architecture string `json:"architecture"`
OS string `json:"os"`
}
type manifestMap map[string]interface{}

View file

@ -0,0 +1,45 @@
package provider
import (
"regexp"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
)
var manifestRegexSet = map[string]*regexp.Regexp{
"sha256_digest": regexp.MustCompile(`\A[A-Za-z0-9_\+\.-]+:[A-Fa-f0-9]+\z`),
"architecture": regexp.MustCompile(`\A(?:amd|amd64|arm|arm64|386|ppc64le|s390x|unknown)\z`),
"os": regexp.MustCompile(`\A(?:linux|unknown)\z`),
"media_type": regexp.MustCompile(`\Aapplication\/vnd\..+\z`),
}
func TestAccDockerRegistryMultiarchImageDataSource_basic(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProviderFactories: providerFactories,
Steps: []resource.TestStep{
{
Config: loadTestConfiguration(t, DATA_SOURCE, "docker_registry_image_manifests", "testAccDockerImageManifestsDataSourceConfig"),
Check: resource.ComposeTestCheckFunc(
resource.TestMatchTypeSetElemNestedAttrs("data.docker_registry_image_manifests.foo", "manifests.*", manifestRegexSet),
),
},
},
})
}
func TestAccDockerRegistryMultiarchImageDataSource_private(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProviderFactories: providerFactories,
Steps: []resource.TestStep{
{
Config: loadTestConfiguration(t, DATA_SOURCE, "docker_registry_image_manifests", "testAccDockerImageManifestsDataSourcePrivateConfig"),
Check: resource.ComposeTestCheckFunc(
resource.TestMatchTypeSetElemNestedAttrs("data.docker_registry_image_manifests.bar", "manifests.*", manifestRegexSet),
),
},
},
})
}

View file

@ -169,11 +169,12 @@ func New(version string) func() *schema.Provider {
},
DataSourcesMap: map[string]*schema.Resource{
"docker_registry_image": dataSourceDockerRegistryImage(),
"docker_network": dataSourceDockerNetwork(),
"docker_plugin": dataSourceDockerPlugin(),
"docker_image": dataSourceDockerImage(),
"docker_logs": dataSourceDockerLogs(),
"docker_registry_image": dataSourceDockerRegistryImage(),
"docker_network": dataSourceDockerNetwork(),
"docker_plugin": dataSourceDockerPlugin(),
"docker_image": dataSourceDockerImage(),
"docker_logs": dataSourceDockerLogs(),
"docker_registry_image_manifests": dataSourceDockerRegistryImageManifests(),
},
}

View file

@ -48,32 +48,7 @@ func resourceDockerRegistryImage() *schema.Resource {
Computed: true,
},
"auth_config": {
Type: schema.TypeList,
Description: "Authentication configuration for the Docker registry. It is only used for this resource.",
Optional: true,
MaxItems: 1,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"address": {
Type: schema.TypeString,
Description: "The address of the Docker registry.",
Required: true,
},
"username": {
Type: schema.TypeString,
Description: "The username for the Docker registry.",
Required: true,
},
"password": {
Type: schema.TypeString,
Description: "The password for the Docker registry.",
Required: true,
Sensitive: true,
},
},
},
},
"auth_config": AuthConfigSchema,
},
}
}

View file

@ -296,29 +296,11 @@ func buildHttpClientForRegistry(registryAddressWithProtocol string, insecureSkip
func deleteDockerRegistryImage(pushOpts internalPushImageOptions, registryWithProtocol string, sha256Digest, username, password string, insecureSkipVerify, fallback bool) error {
client := buildHttpClientForRegistry(registryWithProtocol, insecureSkipVerify)
req, err := http.NewRequest("DELETE", registryWithProtocol+"/v2/"+pushOpts.Repository+"/manifests/"+sha256Digest, nil)
req, err := setupHTTPRequestForRegistry("DELETE", pushOpts.Registry, registryWithProtocol, pushOpts.Repository, sha256Digest, username, password, fallback)
if err != nil {
return fmt.Errorf("Error deleting registry image: %s", err)
return err
}
if username != "" {
if pushOpts.Registry != "ghcr.io" && !isECRRepositoryURL(pushOpts.Registry) && !isAzureCRRepositoryURL(pushOpts.Registry) && pushOpts.Registry != "gcr.io" {
req.SetBasicAuth(username, password)
} else {
if isECRPublicRepositoryURL(pushOpts.Registry) {
password = normalizeECRPasswordForHTTPUsage(password)
req.Header.Add("Authorization", "Bearer "+password)
} else if isECRRepositoryURL(pushOpts.Registry) {
password = normalizeECRPasswordForHTTPUsage(password)
req.Header.Add("Authorization", "Basic "+password)
} else {
req.Header.Add("Authorization", "Bearer "+base64.StdEncoding.EncodeToString([]byte(password)))
}
}
}
setupHTTPHeadersForRegistryRequests(req, fallback)
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("Error during registry request: %s", err)
@ -331,11 +313,12 @@ func deleteDockerRegistryImage(pushOpts internalPushImageOptions, registryWithPr
// Either OAuth is required or the basic auth creds were invalid
case http.StatusUnauthorized:
if !strings.HasPrefix(resp.Header.Get("www-authenticate"), "Bearer") {
return fmt.Errorf("Bad credentials: " + resp.Status)
auth, err := parseAuthHeader(resp.Header.Get("www-authenticate"))
if err != nil {
return fmt.Errorf("bad credentials: %s", resp.Status)
}
token, err := getAuthToken(resp.Header.Get("www-authenticate"), username, password, client)
token, err := getAuthToken(auth, username, password, client)
if err != nil {
return err
}

View file

@ -0,0 +1,3 @@
data "docker_registry_image_manifests" "foo" {
name = "alpine:latest"
}

View file

@ -0,0 +1,3 @@
data "docker_registry_image_manifests" "bar" {
name = "gcr.io:443/google_containers/pause:3.2"
}