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 +}