From eb32f488ac45f8de1951a5dbf5e983f8a8fa27aa Mon Sep 17 00:00:00 2001 From: David Glasser Date: Tue, 5 Apr 2016 10:23:38 -0700 Subject: [PATCH] new provider: dockerregistry (Initial draft. This commit does not include doc updates, and all of the code is in a single file instead of being split into provider/resource files as is the terraform standard.) Unlike the 'docker' provider, which talks to your local Docker daemon to read and write your local image stores, the 'dockerregistry' provider talks to a Docker Registry V1 server and confirms that the named repository/tag exists. It is a read-only provider: it doesn't push to the registry; it just helps you assert that the docker tag push via your build process actually exists before creating other configuration that uses it. Ideally this would use the `data` feature from #4169 instead. --- builtin/bins/provider-dockerregistry/main.go | 12 ++ .../dockerregistry/dockerregistry.go | 117 +++++++++++++++ .../dockerregistry/dockerregistry_test.go | 142 ++++++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 builtin/bins/provider-dockerregistry/main.go create mode 100644 builtin/providers/dockerregistry/dockerregistry.go create mode 100644 builtin/providers/dockerregistry/dockerregistry_test.go diff --git a/builtin/bins/provider-dockerregistry/main.go b/builtin/bins/provider-dockerregistry/main.go new file mode 100644 index 000000000000..b312e8274d0f --- /dev/null +++ b/builtin/bins/provider-dockerregistry/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "github.com/hashicorp/terraform/builtin/providers/dockerregistry" + "github.com/hashicorp/terraform/plugin" +) + +func main() { + plugin.Serve(&plugin.ServeOpts{ + ProviderFunc: dockerregistry.Provider, + }) +} diff --git a/builtin/providers/dockerregistry/dockerregistry.go b/builtin/providers/dockerregistry/dockerregistry.go new file mode 100644 index 000000000000..50802ed11096 --- /dev/null +++ b/builtin/providers/dockerregistry/dockerregistry.go @@ -0,0 +1,117 @@ +package dockerregistry + +import ( + "fmt" + "net/http" + + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/terraform" + "github.com/meteor/docker-registry-client/registry" +) + +func Provider() terraform.ResourceProvider { + return &schema.Provider{ + Schema: map[string]*schema.Schema{ + "username": { + Type: schema.TypeString, + Optional: true, + Description: "Username to log in to Docker registry", + DefaultFunc: schema.EnvDefaultFunc("DOCKERREGISTRY_USERNAME", ""), + }, + "password": { + Type: schema.TypeString, + Optional: true, + Description: "Password to log in to Docker registry", + DefaultFunc: schema.EnvDefaultFunc("DOCKERREGISTRY_PASSWORD", ""), + }, + "registry": { + Type: schema.TypeString, + Optional: true, + Default: "https://registry-1.docker.io", + Description: "URL to Docker V2 registry", + }, + }, + ResourcesMap: map[string]*schema.Resource{ + "dockerregistry_image": { + Schema: map[string]*schema.Schema{ + "repository": { + Type: schema.TypeString, + Required: true, + ForceNew: true, // No Update command; we mutate Id + Description: "Name of the repository in the registry; eg, `mycompany/myproject` or `library/alpine`", + }, + "tag": { + Type: schema.TypeString, + Required: true, + ForceNew: true, // No Update command; we mutate Id + Description: "Tag to search for", + }, + }, + + Create: func(d *schema.ResourceData, meta interface{}) error { + id, err := ensureImageExists(d, meta) + if err != nil { + return err + } + d.SetId(id) + return nil + }, + Read: func(d *schema.ResourceData, meta interface{}) error { + // Don't refresh. If we've changed the resource from something broken + // to something good, we don't want to error just because the state + // file contains the broken resource! It would be nice to be able to + // only refresh if the resource hasn't changed, but that's not how the + // API works. + return nil + }, + Delete: func(d *schema.ResourceData, meta interface{}) error { + d.SetId("") + return nil + }, + }, + }, + ConfigureFunc: providerConfigure, + } +} + +func providerConfigure(d *schema.ResourceData) (interface{}, error) { + registryURL := d.Get("registry").(string) + reg := ®istry.Registry{ + URL: registryURL, + Client: &http.Client{ + Transport: registry.WrapTransport(http.DefaultTransport, registryURL, + d.Get("username").(string), d.Get("password").(string)), + }, + Logf: registry.Quiet, + } + return reg, nil +} + +func ensureImageExists(d *schema.ResourceData, meta interface{}) (string, error) { + reg := meta.(*registry.Registry) + repository := d.Get("repository").(string) + tag := d.Get("tag").(string) + + serverTags, err := reg.Tags(repository) + if err != nil { + return "", fmt.Errorf("Error looking up tags for %s: %s", repository, err) + } + + if !stringInSlice(tag, serverTags) { + return "", fmt.Errorf("Docker image %s:%s not found in registry", repository, tag) + } + + return fmt.Sprintf("%s:%s", repository, tag), nil +} + +// stringInSlice returns true if the string is an element of the slice. +// +// (It's great that Go makes it hard to ignore that this operation is O(n)!) +func stringInSlice(a string, list []string) bool { + for _, b := range list { + if b == a { + return true + } + } + return false +} diff --git a/builtin/providers/dockerregistry/dockerregistry_test.go b/builtin/providers/dockerregistry/dockerregistry_test.go new file mode 100644 index 000000000000..dc96f36ade98 --- /dev/null +++ b/builtin/providers/dockerregistry/dockerregistry_test.go @@ -0,0 +1,142 @@ +package dockerregistry + +// Note: to run the tests that actually talk to the registry, run: +// +// $ TF_ACC=1 go test -v github.com/meteor/amsterdam/cmds/terraform-provider-dockerregistry +// +// (Username and password are not necessary as it only reads a public +// repository. No tests currently test auth.) + +import ( + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/terraform" +) + +var testAccProviders map[string]terraform.ResourceProvider +var testAccProvider *schema.Provider + +func init() { + testAccProvider = Provider().(*schema.Provider) + testAccProviders = map[string]terraform.ResourceProvider{ + "dockerregistry": testAccProvider, + } +} + +func TestProvider(t *testing.T) { + if err := Provider().(*schema.Provider).InternalValidate(); err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestAccDockerRegistry_good(t *testing.T) { + resource.Test(t, resource.TestCase{ + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccDockerRegistryConfigGood, + Check: resource.TestCheckResourceAttr("dockerregistry_image.good", "id", "library/alpine:3.1"), + }, + }, + }) +} + +func TestAccDockerRegistry_missing_tag(t *testing.T) { + expectT := &expectOneErrorTestT{ + ErrorPredicate: func(args ...interface{}) bool { + return len(args) == 1 && strings.Contains(args[0].(string), + "Docker image library/alpine:3.1-does-not-exist not found in registry") + }, + WrappedT: t, + } + resource.Test(expectT, resource.TestCase{ + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccDockerRegistryConfigMissingTag, + }, + }, + }) + if !expectT.GotError { + t.Error("Did not get expected error") + } +} + +func TestAccDockerRegistry_missing_repo(t *testing.T) { + expectT := &expectOneErrorTestT{ + ErrorPredicate: func(args ...interface{}) bool { + if len(args) != 1 { + return false + } + e := args[0].(string) + // This is not the best error, but it's what the registry gives us. + return strings.Contains(e, "Error looking up tags for library/alpine-does-not-exist") && + strings.Contains(e, "UNAUTHORIZED") + }, + WrappedT: t, + } + resource.Test(expectT, resource.TestCase{ + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccDockerRegistryConfigMissingRepo, + }, + }, + }) + if !expectT.GotError { + t.Error("Did not get expected error") + } +} + +const testAccDockerRegistryConfigGood = ` +resource "dockerregistry_image" "good" { + repository = "library/alpine" + tag = "3.1" +} +` + +const testAccDockerRegistryConfigMissingTag = ` +resource "dockerregistry_image" "bad" { + repository = "library/alpine" + tag = "3.1-does-not-exist" +} +` + +const testAccDockerRegistryConfigMissingRepo = ` +resource "dockerregistry_image" "bad" { + repository = "library/alpine-does-not-exist" + tag = "3.1" +} +` + +// This implements the terraform TestT interface and expects to have Error +// called on it exactly once. Skip is passed through. +type expectOneErrorTestT struct { + ErrorPredicate func(args ...interface{}) bool + WrappedT *testing.T + GotError bool +} + +func (t *expectOneErrorTestT) Fatal(args ...interface{}) { + t.WrappedT.Fatal(args...) +} + +func (t *expectOneErrorTestT) Skip(args ...interface{}) { + t.WrappedT.Skip(args...) +} + +func (t *expectOneErrorTestT) Error(args ...interface{}) { + if t.GotError { + t.WrappedT.Error("Got unexpected additional error:", fmt.Sprintln(args...)) + return + } + if !t.ErrorPredicate(args...) { + t.WrappedT.Error("Got non-matching error:", fmt.Sprintln(args...)) + return + } + t.GotError = true +}