diff --git a/builtin/providers/docker/data_source_docker_registry_image.go b/builtin/providers/docker/data_source_docker_registry_image.go new file mode 100644 index 000000000..9898c8ac8 --- /dev/null +++ b/builtin/providers/docker/data_source_docker_registry_image.go @@ -0,0 +1,166 @@ +package docker + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strings" + + "github.com/hashicorp/terraform/helper/schema" +) + +func dataSourceDockerRegistryImage() *schema.Resource { + return &schema.Resource{ + Read: dataSourceDockerRegistryImageRead, + + Schema: map[string]*schema.Schema{ + "name": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + + "sha256_digest": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func dataSourceDockerRegistryImageRead(d *schema.ResourceData, meta interface{}) error { + pullOpts := parseImageOptions(d.Get("name").(string)) + + // Use the official Docker Hub if a registry isn't specified + if pullOpts.Registry == "" { + pullOpts.Registry = "registry.hub.docker.com" + } else { + // Otherwise, filter the registry name out of the repo name + pullOpts.Repository = strings.Replace(pullOpts.Repository, pullOpts.Registry+"/", "", 1) + } + + // Docker prefixes 'library' to official images in the path; 'consul' becomes 'library/consul' + if !strings.Contains(pullOpts.Repository, "/") { + pullOpts.Repository = "library/" + pullOpts.Repository + } + + if pullOpts.Tag == "" { + pullOpts.Tag = "latest" + } + + digest, err := getImageDigest(pullOpts.Registry, pullOpts.Repository, pullOpts.Tag, "", "") + + if err != nil { + return fmt.Errorf("Got error when attempting to fetch image version from registry: %s", err) + } + + d.SetId(digest) + d.Set("sha256_digest", digest) + + return nil +} + +func getImageDigest(registry, image, tag, username, password string) (string, error) { + client := http.DefaultClient + + req, err := http.NewRequest("GET", "https://"+registry+"/v2/"+image+"/manifests/"+tag, nil) + + if err != nil { + return "", fmt.Errorf("Error creating registry request: %s", err) + } + + if username != "" { + req.SetBasicAuth(username, password) + } + + resp, err := client.Do(req) + + if err != nil { + return "", fmt.Errorf("Error during registry request: %s", err) + } + + switch resp.StatusCode { + // Basic auth was valid or not needed + case http.StatusOK: + return resp.Header.Get("Docker-Content-Digest"), nil + + // Either OAuth is required or the basic auth creds were invalid + case http.StatusUnauthorized: + if strings.HasPrefix(resp.Header.Get("www-authenticate"), "Bearer") { + auth := parseAuthHeader(resp.Header.Get("www-authenticate")) + 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 := ioutil.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) + } + + req.Header.Set("Authorization", "Bearer "+token.Token) + digestResponse, err := client.Do(req) + + if err != nil { + return "", fmt.Errorf("Error during registry request: %s", err) + } + + if digestResponse.StatusCode != http.StatusOK { + return "", fmt.Errorf("Got bad response from registry: " + digestResponse.Status) + } + + return digestResponse.Header.Get("Docker-Content-Digest"), nil + } else { + return "", fmt.Errorf("Bad credentials: " + resp.Status) + } + + // Some unexpected status was given, return an error + default: + return "", fmt.Errorf("Got bad response from registry: " + resp.Status) + } +} + +type TokenResponse struct { + Token string +} + +// Parses key/value pairs from a WWW-Authenticate header +func parseAuthHeader(header string) map[string]string { + parts := strings.SplitN(header, " ", 2) + parts = strings.Split(parts[1], ",") + 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 +} diff --git a/builtin/providers/docker/data_source_docker_registry_image_test.go b/builtin/providers/docker/data_source_docker_registry_image_test.go new file mode 100644 index 000000000..aa34b004b --- /dev/null +++ b/builtin/providers/docker/data_source_docker_registry_image_test.go @@ -0,0 +1,52 @@ +package docker + +import ( + "regexp" + "testing" + + "github.com/hashicorp/terraform/helper/resource" +) + +var registryDigestRegexp = regexp.MustCompile(`\A[A-Za-z0-9_\+\.-]+:[A-Fa-f0-9]+\z`) + +func TestAccDockerRegistryImage_basic(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccDockerImageDataSourceConfig, + Check: resource.ComposeTestCheckFunc( + resource.TestMatchResourceAttr("data.docker_registry_image.foo", "sha256_digest", registryDigestRegexp), + ), + }, + }, + }) +} + +func TestAccDockerRegistryImage_private(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccDockerImageDataSourcePrivateConfig, + Check: resource.ComposeTestCheckFunc( + resource.TestMatchResourceAttr("data.docker_registry_image.bar", "sha256_digest", registryDigestRegexp), + ), + }, + }, + }) +} + +const testAccDockerImageDataSourceConfig = ` +data "docker_registry_image" "foo" { + name = "alpine:latest" +} +` + +const testAccDockerImageDataSourcePrivateConfig = ` +data "docker_registry_image" "bar" { + name = "gcr.io:443/google_containers/pause:0.8.0" +} +` diff --git a/builtin/providers/docker/provider.go b/builtin/providers/docker/provider.go index cc25e1430..cee438ae3 100644 --- a/builtin/providers/docker/provider.go +++ b/builtin/providers/docker/provider.go @@ -32,6 +32,10 @@ func Provider() terraform.ResourceProvider { "docker_volume": resourceDockerVolume(), }, + DataSourcesMap: map[string]*schema.Resource{ + "docker_registry_image": dataSourceDockerRegistryImage(), + }, + ConfigureFunc: providerConfigure, } } diff --git a/builtin/providers/docker/resource_docker_image.go b/builtin/providers/docker/resource_docker_image.go index 09b6d32b8..9c2f84d48 100644 --- a/builtin/providers/docker/resource_docker_image.go +++ b/builtin/providers/docker/resource_docker_image.go @@ -17,11 +17,6 @@ func resourceDockerImage() *schema.Resource { Required: true, }, - "keep_updated": &schema.Schema{ - Type: schema.TypeBool, - Optional: true, - }, - "latest": &schema.Schema{ Type: schema.TypeString, Computed: true, @@ -31,6 +26,12 @@ func resourceDockerImage() *schema.Resource { Type: schema.TypeBool, Optional: true, }, + + "pull_trigger": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, }, } } diff --git a/builtin/providers/docker/resource_docker_image_funcs.go b/builtin/providers/docker/resource_docker_image_funcs.go index 03d287c0b..72cbc8ea2 100644 --- a/builtin/providers/docker/resource_docker_image_funcs.go +++ b/builtin/providers/docker/resource_docker_image_funcs.go @@ -22,6 +22,25 @@ func resourceDockerImageCreate(d *schema.ResourceData, meta interface{}) error { } func resourceDockerImageRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*dc.Client) + var data Data + if err := fetchLocalImages(&data, client); err != nil { + return fmt.Errorf("Error reading docker image list: %s", err) + } + foundImage := searchLocalImages(data, d.Get("name").(string)) + + if foundImage != nil { + d.Set("latest", foundImage.ID) + } else { + d.SetId("") + } + + return nil +} + +func resourceDockerImageUpdate(d *schema.ResourceData, meta interface{}) error { + // We need to re-read in case switching parameters affects + // the value of "latest" or others client := meta.(*dc.Client) apiImage, err := findImage(d, client) if err != nil { @@ -33,13 +52,6 @@ func resourceDockerImageRead(d *schema.ResourceData, meta interface{}) error { return nil } -func resourceDockerImageUpdate(d *schema.ResourceData, meta interface{}) error { - // We need to re-read in case switching parameters affects - // the value of "latest" or others - - return resourceDockerImageRead(d, meta) -} - func resourceDockerImageDelete(d *schema.ResourceData, meta interface{}) error { client := meta.(*dc.Client) err := removeImage(d, client) @@ -117,6 +129,17 @@ func pullImage(data *Data, client *dc.Client, image string) error { // TODO: Test local registry handling. It should be working // based on the code that was ported over + pullOpts := parseImageOptions(image) + auth := dc.AuthConfiguration{} + + if err := client.PullImage(pullOpts, auth); err != nil { + return fmt.Errorf("Error pulling image %s: %s\n", image, err) + } + + return fetchLocalImages(data, client) +} + +func parseImageOptions(image string) dc.PullImageOptions { pullOpts := dc.PullImageOptions{} splitImageName := strings.Split(image, ":") @@ -151,32 +174,7 @@ func pullImage(data *Data, client *dc.Client, image string) error { pullOpts.Repository = image } - if err := client.PullImage(pullOpts, dc.AuthConfiguration{}); err != nil { - return fmt.Errorf("Error pulling image %s: %s\n", image, err) - } - - return fetchLocalImages(data, client) -} - -func getImageTag(image string) string { - splitImageName := strings.Split(image, ":") - switch { - - // It's in registry:port/repo:tag format - case len(splitImageName) == 3: - return splitImageName[2] - - // It's either registry:port/repo or repo:tag with default registry - case len(splitImageName) == 2: - splitPortRepo := strings.Split(splitImageName[1], "/") - if len(splitPortRepo) == 2 { - return "" - } else { - return splitImageName[1] - } - } - - return "" + return pullOpts } func findImage(d *schema.ResourceData, client *dc.Client) (*dc.APIImages, error) { @@ -192,7 +190,7 @@ func findImage(d *schema.ResourceData, client *dc.Client) (*dc.APIImages, error) foundImage := searchLocalImages(data, imageName) - if d.Get("keep_updated").(bool) || foundImage == nil { + if foundImage == nil { if err := pullImage(&data, client, imageName); err != nil { return nil, fmt.Errorf("Unable to pull image %s: %s", imageName, err) } diff --git a/builtin/providers/docker/resource_docker_image_test.go b/builtin/providers/docker/resource_docker_image_test.go index adf35fd9f..484c45e83 100644 --- a/builtin/providers/docker/resource_docker_image_test.go +++ b/builtin/providers/docker/resource_docker_image_test.go @@ -73,6 +73,22 @@ func TestAccDockerImage_destroy(t *testing.T) { }) } +func TestAccDockerImage_data(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + PreventPostDestroyRefresh: true, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccDockerImageFromDataConfig, + Check: resource.ComposeTestCheckFunc( + resource.TestMatchResourceAttr("docker_image.foobarbaz", "latest", contentDigestRegexp), + ), + }, + }, + }) +} + func testAccDockerImageDestroy(s *terraform.State) error { for _, rs := range s.RootModule().Resources { if rs.Type != "docker_image" { @@ -93,14 +109,12 @@ func testAccDockerImageDestroy(s *terraform.State) error { const testAccDockerImageConfig = ` resource "docker_image" "foo" { name = "alpine:3.1" - keep_updated = false } ` const testAddDockerPrivateImageConfig = ` resource "docker_image" "foobar" { name = "gcr.io:443/google_containers/pause:0.8.0" - keep_updated = true } ` @@ -110,3 +124,13 @@ resource "docker_image" "foobarzoo" { keep_locally = true } ` + +const testAccDockerImageFromDataConfig = ` +data "docker_registry_image" "foobarbaz" { + name = "alpine:3.1" +} +resource "docker_image" "foobarbaz" { + name = "${data.docker_registry_image.foobarbaz.name}" + pull_trigger = "${data.docker_registry_image.foobarbaz.sha256_digest}" +} +`