diff --git a/docker/config.go b/docker/config.go new file mode 100644 index 00000000..ad05d540 --- /dev/null +++ b/docker/config.go @@ -0,0 +1,49 @@ +package docker + +import ( + "fmt" + "path/filepath" + + dc "github.com/fsouza/go-dockerclient" +) + +// Config is the structure that stores the configuration to talk to a +// Docker API compatible host. +type Config struct { + Host string + Ca string + Cert string + Key string + CertPath string +} + +// NewClient() returns a new Docker client. +func (c *Config) NewClient() (*dc.Client, error) { + if c.Ca != "" || c.Cert != "" || c.Key != "" { + if c.Ca == "" || c.Cert == "" || c.Key == "" { + return nil, fmt.Errorf("ca_material, cert_material, and key_material must be specified") + } + + if c.CertPath != "" { + return nil, fmt.Errorf("cert_path must not be specified") + } + + return dc.NewTLSClientFromBytes(c.Host, []byte(c.Cert), []byte(c.Key), []byte(c.Ca)) + } + + if c.CertPath != "" { + // If there is cert information, load it and use it. + ca := filepath.Join(c.CertPath, "ca.pem") + cert := filepath.Join(c.CertPath, "cert.pem") + key := filepath.Join(c.CertPath, "key.pem") + return dc.NewTLSClient(c.Host, cert, key, ca) + } + + // If there is no cert information, then just return the direct client + return dc.NewClient(c.Host) +} + +// Data ia structure for holding data that we fetch from Docker. +type Data struct { + DockerImages map[string]*dc.APIImages +} diff --git a/docker/data_source_docker_registry_image.go b/docker/data_source_docker_registry_image.go new file mode 100644 index 00000000..9898c8ac --- /dev/null +++ b/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/docker/data_source_docker_registry_image_test.go b/docker/data_source_docker_registry_image_test.go new file mode 100644 index 00000000..aa34b004 --- /dev/null +++ b/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/docker/provider.go b/docker/provider.go new file mode 100644 index 00000000..1da7ffbe --- /dev/null +++ b/docker/provider.go @@ -0,0 +1,82 @@ +package docker + +import ( + "fmt" + + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/terraform" +) + +func Provider() terraform.ResourceProvider { + return &schema.Provider{ + Schema: map[string]*schema.Schema{ + "host": &schema.Schema{ + Type: schema.TypeString, + Required: true, + DefaultFunc: schema.EnvDefaultFunc("DOCKER_HOST", "unix:///var/run/docker.sock"), + Description: "The Docker daemon address", + }, + + "ca_material": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("DOCKER_CA_MATERIAL", ""), + Description: "PEM-encoded content of Docker host CA certificate", + }, + "cert_material": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("DOCKER_CERT_MATERIAL", ""), + Description: "PEM-encoded content of Docker client certificate", + }, + "key_material": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("DOCKER_KEY_MATERIAL", ""), + Description: "PEM-encoded content of Docker client private key", + }, + + "cert_path": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("DOCKER_CERT_PATH", ""), + Description: "Path to directory with Docker TLS config", + }, + }, + + ResourcesMap: map[string]*schema.Resource{ + "docker_container": resourceDockerContainer(), + "docker_image": resourceDockerImage(), + "docker_network": resourceDockerNetwork(), + "docker_volume": resourceDockerVolume(), + }, + + DataSourcesMap: map[string]*schema.Resource{ + "docker_registry_image": dataSourceDockerRegistryImage(), + }, + + ConfigureFunc: providerConfigure, + } +} + +func providerConfigure(d *schema.ResourceData) (interface{}, error) { + config := Config{ + Host: d.Get("host").(string), + Ca: d.Get("ca_material").(string), + Cert: d.Get("cert_material").(string), + Key: d.Get("key_material").(string), + CertPath: d.Get("cert_path").(string), + } + + client, err := config.NewClient() + if err != nil { + return nil, fmt.Errorf("Error initializing Docker client: %s", err) + } + + err = client.Ping() + if err != nil { + return nil, fmt.Errorf("Error pinging Docker server: %s", err) + } + + return client, nil +} diff --git a/docker/provider_test.go b/docker/provider_test.go new file mode 100644 index 00000000..d0910488 --- /dev/null +++ b/docker/provider_test.go @@ -0,0 +1,36 @@ +package docker + +import ( + "os/exec" + "testing" + + "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{ + "docker": testAccProvider, + } +} + +func TestProvider(t *testing.T) { + if err := Provider().(*schema.Provider).InternalValidate(); err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestProvider_impl(t *testing.T) { + var _ terraform.ResourceProvider = Provider() +} + +func testAccPreCheck(t *testing.T) { + cmd := exec.Command("docker", "version") + if err := cmd.Run(); err != nil { + t.Fatalf("Docker must be available: %s", err) + } +} diff --git a/docker/resource_docker_container.go b/docker/resource_docker_container.go new file mode 100644 index 00000000..543dc930 --- /dev/null +++ b/docker/resource_docker_container.go @@ -0,0 +1,528 @@ +package docker + +import ( + "bytes" + "fmt" + + "regexp" + + "github.com/hashicorp/terraform/helper/hashcode" + "github.com/hashicorp/terraform/helper/schema" +) + +func resourceDockerContainer() *schema.Resource { + return &schema.Resource{ + Create: resourceDockerContainerCreate, + Read: resourceDockerContainerRead, + Update: resourceDockerContainerUpdate, + Delete: resourceDockerContainerDelete, + + Schema: map[string]*schema.Schema{ + "name": &schema.Schema{ + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + + // Indicates whether the container must be running. + // + // An assumption is made that configured containers + // should be running; if not, they should not be in + // the configuration. Therefore a stopped container + // should be started. Set to false to have the + // provider leave the container alone. + // + // Actively-debugged containers are likely to be + // stopped and started manually, and Docker has + // some provisions for restarting containers that + // stop. The utility here comes from the fact that + // this will delete and re-create the container + // following the principle that the containers + // should be pristine when started. + "must_run": &schema.Schema{ + Type: schema.TypeBool, + Default: true, + Optional: true, + }, + + // ForceNew is not true for image because we need to + // sane this against Docker image IDs, as each image + // can have multiple names/tags attached do it. + "image": &schema.Schema{ + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + + "hostname": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + + "domainname": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + + "command": &schema.Schema{ + Type: schema.TypeList, + Optional: true, + ForceNew: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + + "entrypoint": &schema.Schema{ + Type: schema.TypeList, + Optional: true, + ForceNew: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + + "user": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + + "dns": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + ForceNew: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Set: schema.HashString, + }, + + "dns_opts": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + ForceNew: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Set: schema.HashString, + }, + + "dns_search": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + ForceNew: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Set: schema.HashString, + }, + + "publish_all_ports": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + ForceNew: true, + }, + + "restart": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Default: "no", + ValidateFunc: func(v interface{}, k string) (ws []string, es []error) { + value := v.(string) + if !regexp.MustCompile(`^(no|on-failure|always|unless-stopped)$`).MatchString(value) { + es = append(es, fmt.Errorf( + "%q must be one of \"no\", \"on-failure\", \"always\" or \"unless-stopped\"", k)) + } + return + }, + }, + + "max_retry_count": &schema.Schema{ + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + }, + + "capabilities": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + ForceNew: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "add": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + ForceNew: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Set: schema.HashString, + }, + + "drop": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + ForceNew: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Set: schema.HashString, + }, + }, + }, + Set: resourceDockerCapabilitiesHash, + }, + + "volumes": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + ForceNew: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "from_container": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + + "container_path": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + + "host_path": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ValidateFunc: validateDockerContainerPath, + }, + + "volume_name": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + + "read_only": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + ForceNew: true, + }, + }, + }, + Set: resourceDockerVolumesHash, + }, + + "ports": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + ForceNew: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "internal": &schema.Schema{ + Type: schema.TypeInt, + Required: true, + ForceNew: true, + }, + + "external": &schema.Schema{ + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + }, + + "ip": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + + "protocol": &schema.Schema{ + Type: schema.TypeString, + Default: "tcp", + Optional: true, + ForceNew: true, + }, + }, + }, + Set: resourceDockerPortsHash, + }, + + "host": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + ForceNew: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "ip": &schema.Schema{ + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + + "host": &schema.Schema{ + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + }, + }, + Set: resourceDockerHostsHash, + }, + + "env": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + ForceNew: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Set: schema.HashString, + }, + + "links": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + ForceNew: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Set: schema.HashString, + }, + + "ip_address": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + }, + + "ip_prefix_length": &schema.Schema{ + Type: schema.TypeInt, + Computed: true, + }, + + "gateway": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + }, + + "bridge": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + }, + + "privileged": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + ForceNew: true, + }, + + "destroy_grace_seconds": &schema.Schema{ + Type: schema.TypeInt, + Optional: true, + }, + + "labels": &schema.Schema{ + Type: schema.TypeMap, + Optional: true, + ForceNew: true, + }, + + "memory": &schema.Schema{ + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + ValidateFunc: func(v interface{}, k string) (ws []string, es []error) { + value := v.(int) + if value < 0 { + es = append(es, fmt.Errorf("%q must be greater than or equal to 0", k)) + } + return + }, + }, + + "memory_swap": &schema.Schema{ + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + ValidateFunc: func(v interface{}, k string) (ws []string, es []error) { + value := v.(int) + if value < -1 { + es = append(es, fmt.Errorf("%q must be greater than or equal to -1", k)) + } + return + }, + }, + + "cpu_shares": &schema.Schema{ + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + ValidateFunc: func(v interface{}, k string) (ws []string, es []error) { + value := v.(int) + if value < 0 { + es = append(es, fmt.Errorf("%q must be greater than or equal to 0", k)) + } + return + }, + }, + + "log_driver": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Default: "json-file", + ValidateFunc: func(v interface{}, k string) (ws []string, es []error) { + value := v.(string) + if !regexp.MustCompile(`^(json-file|syslog|journald|gelf|fluentd)$`).MatchString(value) { + es = append(es, fmt.Errorf( + "%q must be one of \"json-file\", \"syslog\", \"journald\", \"gelf\", or \"fluentd\"", k)) + } + return + }, + }, + + "log_opts": &schema.Schema{ + Type: schema.TypeMap, + Optional: true, + ForceNew: true, + }, + + "network_alias": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + ForceNew: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Set: schema.HashString, + }, + + "network_mode": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + + "networks": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + ForceNew: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Set: schema.HashString, + }, + + "upload": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + ForceNew: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "content": &schema.Schema{ + Type: schema.TypeString, + Required: true, + // This is intentional. The container is mutated once, and never updated later. + // New configuration forces a new deployment, even with the same binaries. + ForceNew: true, + }, + "file": &schema.Schema{ + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + }, + }, + Set: resourceDockerUploadHash, + }, + }, + } +} + +func resourceDockerCapabilitiesHash(v interface{}) int { + var buf bytes.Buffer + m := v.(map[string]interface{}) + + if v, ok := m["add"]; ok { + buf.WriteString(fmt.Sprintf("%v-", v)) + } + + if v, ok := m["remove"]; ok { + buf.WriteString(fmt.Sprintf("%v-", v)) + } + + return hashcode.String(buf.String()) +} + +func resourceDockerPortsHash(v interface{}) int { + var buf bytes.Buffer + m := v.(map[string]interface{}) + + buf.WriteString(fmt.Sprintf("%v-", m["internal"].(int))) + + if v, ok := m["external"]; ok { + buf.WriteString(fmt.Sprintf("%v-", v.(int))) + } + + if v, ok := m["ip"]; ok { + buf.WriteString(fmt.Sprintf("%v-", v.(string))) + } + + if v, ok := m["protocol"]; ok { + buf.WriteString(fmt.Sprintf("%v-", v.(string))) + } + + return hashcode.String(buf.String()) +} + +func resourceDockerHostsHash(v interface{}) int { + var buf bytes.Buffer + m := v.(map[string]interface{}) + + if v, ok := m["ip"]; ok { + buf.WriteString(fmt.Sprintf("%v-", v.(string))) + } + + if v, ok := m["host"]; ok { + buf.WriteString(fmt.Sprintf("%v-", v.(string))) + } + + return hashcode.String(buf.String()) +} + +func resourceDockerVolumesHash(v interface{}) int { + var buf bytes.Buffer + m := v.(map[string]interface{}) + + if v, ok := m["from_container"]; ok { + buf.WriteString(fmt.Sprintf("%v-", v.(string))) + } + + if v, ok := m["container_path"]; ok { + buf.WriteString(fmt.Sprintf("%v-", v.(string))) + } + + if v, ok := m["host_path"]; ok { + buf.WriteString(fmt.Sprintf("%v-", v.(string))) + } + + if v, ok := m["volume_name"]; ok { + buf.WriteString(fmt.Sprintf("%v-", v.(string))) + } + + if v, ok := m["read_only"]; ok { + buf.WriteString(fmt.Sprintf("%v-", v.(bool))) + } + + return hashcode.String(buf.String()) +} + +func resourceDockerUploadHash(v interface{}) int { + var buf bytes.Buffer + m := v.(map[string]interface{}) + + if v, ok := m["content"]; ok { + buf.WriteString(fmt.Sprintf("%v-", v.(string))) + } + + if v, ok := m["file"]; ok { + buf.WriteString(fmt.Sprintf("%v-", v.(string))) + } + + return hashcode.String(buf.String()) +} + +func validateDockerContainerPath(v interface{}, k string) (ws []string, errors []error) { + + value := v.(string) + if !regexp.MustCompile(`^[a-zA-Z]:\\|^/`).MatchString(value) { + errors = append(errors, fmt.Errorf("%q must be an absolute path", k)) + } + + return +} diff --git a/docker/resource_docker_container_funcs.go b/docker/resource_docker_container_funcs.go new file mode 100644 index 00000000..4a494c5b --- /dev/null +++ b/docker/resource_docker_container_funcs.go @@ -0,0 +1,465 @@ +package docker + +import ( + "archive/tar" + "bytes" + "errors" + "fmt" + "strconv" + "time" + + dc "github.com/fsouza/go-dockerclient" + "github.com/hashicorp/terraform/helper/schema" +) + +var ( + creationTime time.Time +) + +func resourceDockerContainerCreate(d *schema.ResourceData, meta interface{}) error { + var err error + client := meta.(*dc.Client) + + var data Data + if err := fetchLocalImages(&data, client); err != nil { + return err + } + + image := d.Get("image").(string) + if _, ok := data.DockerImages[image]; !ok { + if _, ok := data.DockerImages[image+":latest"]; !ok { + return fmt.Errorf("Unable to find image %s", image) + } + image = image + ":latest" + } + + // The awesome, wonderful, splendiferous, sensical + // Docker API now lets you specify a HostConfig in + // CreateContainerOptions, but in my testing it still only + // actually applies HostConfig options set in StartContainer. + // How cool is that? + createOpts := dc.CreateContainerOptions{ + Name: d.Get("name").(string), + Config: &dc.Config{ + Image: image, + Hostname: d.Get("hostname").(string), + Domainname: d.Get("domainname").(string), + }, + } + + if v, ok := d.GetOk("env"); ok { + createOpts.Config.Env = stringSetToStringSlice(v.(*schema.Set)) + } + + if v, ok := d.GetOk("command"); ok { + createOpts.Config.Cmd = stringListToStringSlice(v.([]interface{})) + for _, v := range createOpts.Config.Cmd { + if v == "" { + return fmt.Errorf("values for command may not be empty") + } + } + } + + if v, ok := d.GetOk("entrypoint"); ok { + createOpts.Config.Entrypoint = stringListToStringSlice(v.([]interface{})) + } + + if v, ok := d.GetOk("user"); ok { + createOpts.Config.User = v.(string) + } + + exposedPorts := map[dc.Port]struct{}{} + portBindings := map[dc.Port][]dc.PortBinding{} + + if v, ok := d.GetOk("ports"); ok { + exposedPorts, portBindings = portSetToDockerPorts(v.(*schema.Set)) + } + if len(exposedPorts) != 0 { + createOpts.Config.ExposedPorts = exposedPorts + } + + extraHosts := []string{} + if v, ok := d.GetOk("host"); ok { + extraHosts = extraHostsSetToDockerExtraHosts(v.(*schema.Set)) + } + + volumes := map[string]struct{}{} + binds := []string{} + volumesFrom := []string{} + + if v, ok := d.GetOk("volumes"); ok { + volumes, binds, volumesFrom, err = volumeSetToDockerVolumes(v.(*schema.Set)) + if err != nil { + return fmt.Errorf("Unable to parse volumes: %s", err) + } + } + if len(volumes) != 0 { + createOpts.Config.Volumes = volumes + } + + if v, ok := d.GetOk("labels"); ok { + createOpts.Config.Labels = mapTypeMapValsToString(v.(map[string]interface{})) + } + + hostConfig := &dc.HostConfig{ + Privileged: d.Get("privileged").(bool), + PublishAllPorts: d.Get("publish_all_ports").(bool), + RestartPolicy: dc.RestartPolicy{ + Name: d.Get("restart").(string), + MaximumRetryCount: d.Get("max_retry_count").(int), + }, + LogConfig: dc.LogConfig{ + Type: d.Get("log_driver").(string), + }, + } + + if len(portBindings) != 0 { + hostConfig.PortBindings = portBindings + } + if len(extraHosts) != 0 { + hostConfig.ExtraHosts = extraHosts + } + if len(binds) != 0 { + hostConfig.Binds = binds + } + if len(volumesFrom) != 0 { + hostConfig.VolumesFrom = volumesFrom + } + + if v, ok := d.GetOk("capabilities"); ok { + for _, capInt := range v.(*schema.Set).List() { + capa := capInt.(map[string]interface{}) + hostConfig.CapAdd = stringSetToStringSlice(capa["add"].(*schema.Set)) + hostConfig.CapDrop = stringSetToStringSlice(capa["drop"].(*schema.Set)) + break + } + } + + if v, ok := d.GetOk("dns"); ok { + hostConfig.DNS = stringSetToStringSlice(v.(*schema.Set)) + } + + if v, ok := d.GetOk("dns_opts"); ok { + hostConfig.DNSOptions = stringSetToStringSlice(v.(*schema.Set)) + } + + if v, ok := d.GetOk("dns_search"); ok { + hostConfig.DNSSearch = stringSetToStringSlice(v.(*schema.Set)) + } + + if v, ok := d.GetOk("links"); ok { + hostConfig.Links = stringSetToStringSlice(v.(*schema.Set)) + } + + if v, ok := d.GetOk("memory"); ok { + hostConfig.Memory = int64(v.(int)) * 1024 * 1024 + } + + if v, ok := d.GetOk("memory_swap"); ok { + swap := int64(v.(int)) + if swap > 0 { + swap = swap * 1024 * 1024 + } + hostConfig.MemorySwap = swap + } + + if v, ok := d.GetOk("cpu_shares"); ok { + hostConfig.CPUShares = int64(v.(int)) + } + + if v, ok := d.GetOk("log_opts"); ok { + hostConfig.LogConfig.Config = mapTypeMapValsToString(v.(map[string]interface{})) + } + + if v, ok := d.GetOk("network_mode"); ok { + hostConfig.NetworkMode = v.(string) + } + + createOpts.HostConfig = hostConfig + + var retContainer *dc.Container + if retContainer, err = client.CreateContainer(createOpts); err != nil { + return fmt.Errorf("Unable to create container: %s", err) + } + if retContainer == nil { + return fmt.Errorf("Returned container is nil") + } + + d.SetId(retContainer.ID) + + if v, ok := d.GetOk("networks"); ok { + var connectionOpts dc.NetworkConnectionOptions + if v, ok := d.GetOk("network_alias"); ok { + endpointConfig := &dc.EndpointConfig{} + endpointConfig.Aliases = stringSetToStringSlice(v.(*schema.Set)) + connectionOpts = dc.NetworkConnectionOptions{Container: retContainer.ID, EndpointConfig: endpointConfig} + } else { + connectionOpts = dc.NetworkConnectionOptions{Container: retContainer.ID} + } + + for _, rawNetwork := range v.(*schema.Set).List() { + network := rawNetwork.(string) + if err := client.ConnectNetwork(network, connectionOpts); err != nil { + return fmt.Errorf("Unable to connect to network '%s': %s", network, err) + } + } + } + + if v, ok := d.GetOk("upload"); ok { + for _, upload := range v.(*schema.Set).List() { + content := upload.(map[string]interface{})["content"].(string) + file := upload.(map[string]interface{})["file"].(string) + + buf := new(bytes.Buffer) + tw := tar.NewWriter(buf) + hdr := &tar.Header{ + Name: file, + Mode: 0644, + Size: int64(len(content)), + } + if err := tw.WriteHeader(hdr); err != nil { + return fmt.Errorf("Error creating tar archive: %s", err) + } + if _, err := tw.Write([]byte(content)); err != nil { + return fmt.Errorf("Error creating tar archive: %s", err) + } + if err := tw.Close(); err != nil { + return fmt.Errorf("Error creating tar archive: %s", err) + } + + uploadOpts := dc.UploadToContainerOptions{ + InputStream: bytes.NewReader(buf.Bytes()), + Path: "/", + } + + if err := client.UploadToContainer(retContainer.ID, uploadOpts); err != nil { + return fmt.Errorf("Unable to upload volume content: %s", err) + } + } + } + + creationTime = time.Now() + if err := client.StartContainer(retContainer.ID, nil); err != nil { + return fmt.Errorf("Unable to start container: %s", err) + } + + return resourceDockerContainerRead(d, meta) +} + +func resourceDockerContainerRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*dc.Client) + + apiContainer, err := fetchDockerContainer(d.Id(), client) + if err != nil { + return err + } + if apiContainer == nil { + // This container doesn't exist anymore + d.SetId("") + return nil + } + + var container *dc.Container + + loops := 1 // if it hasn't just been created, don't delay + if !creationTime.IsZero() { + loops = 30 // with 500ms spacing, 15 seconds; ought to be plenty + } + sleepTime := 500 * time.Millisecond + + for i := loops; i > 0; i-- { + container, err = client.InspectContainer(apiContainer.ID) + if err != nil { + return fmt.Errorf("Error inspecting container %s: %s", apiContainer.ID, err) + } + + if container.State.Running || + !container.State.Running && !d.Get("must_run").(bool) { + break + } + + if creationTime.IsZero() { // We didn't just create it, so don't wait around + return resourceDockerContainerDelete(d, meta) + } + + if container.State.FinishedAt.After(creationTime) { + // It exited immediately, so error out so dependent containers + // aren't started + resourceDockerContainerDelete(d, meta) + return fmt.Errorf("Container %s exited after creation, error was: %s", apiContainer.ID, container.State.Error) + } + + time.Sleep(sleepTime) + } + + // Handle the case of the for loop above running its course + if !container.State.Running && d.Get("must_run").(bool) { + resourceDockerContainerDelete(d, meta) + return fmt.Errorf("Container %s failed to be in running state", apiContainer.ID) + } + + // Read Network Settings + if container.NetworkSettings != nil { + d.Set("ip_address", container.NetworkSettings.IPAddress) + d.Set("ip_prefix_length", container.NetworkSettings.IPPrefixLen) + d.Set("gateway", container.NetworkSettings.Gateway) + d.Set("bridge", container.NetworkSettings.Bridge) + } + + return nil +} + +func resourceDockerContainerUpdate(d *schema.ResourceData, meta interface{}) error { + return nil +} + +func resourceDockerContainerDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*dc.Client) + + // Stop the container before removing if destroy_grace_seconds is defined + if d.Get("destroy_grace_seconds").(int) > 0 { + var timeout = uint(d.Get("destroy_grace_seconds").(int)) + if err := client.StopContainer(d.Id(), timeout); err != nil { + return fmt.Errorf("Error stopping container %s: %s", d.Id(), err) + } + } + + removeOpts := dc.RemoveContainerOptions{ + ID: d.Id(), + RemoveVolumes: true, + Force: true, + } + + if err := client.RemoveContainer(removeOpts); err != nil { + return fmt.Errorf("Error deleting container %s: %s", d.Id(), err) + } + + d.SetId("") + return nil +} + +func stringListToStringSlice(stringList []interface{}) []string { + ret := []string{} + for _, v := range stringList { + if v == nil { + ret = append(ret, "") + continue + } + ret = append(ret, v.(string)) + } + return ret +} + +func stringSetToStringSlice(stringSet *schema.Set) []string { + ret := []string{} + if stringSet == nil { + return ret + } + for _, envVal := range stringSet.List() { + ret = append(ret, envVal.(string)) + } + return ret +} + +func mapTypeMapValsToString(typeMap map[string]interface{}) map[string]string { + mapped := make(map[string]string, len(typeMap)) + for k, v := range typeMap { + mapped[k] = v.(string) + } + return mapped +} + +func fetchDockerContainer(ID string, client *dc.Client) (*dc.APIContainers, error) { + apiContainers, err := client.ListContainers(dc.ListContainersOptions{All: true}) + + if err != nil { + return nil, fmt.Errorf("Error fetching container information from Docker: %s\n", err) + } + + for _, apiContainer := range apiContainers { + if apiContainer.ID == ID { + return &apiContainer, nil + } + } + + return nil, nil +} + +func portSetToDockerPorts(ports *schema.Set) (map[dc.Port]struct{}, map[dc.Port][]dc.PortBinding) { + retExposedPorts := map[dc.Port]struct{}{} + retPortBindings := map[dc.Port][]dc.PortBinding{} + + for _, portInt := range ports.List() { + port := portInt.(map[string]interface{}) + internal := port["internal"].(int) + protocol := port["protocol"].(string) + + exposedPort := dc.Port(strconv.Itoa(internal) + "/" + protocol) + retExposedPorts[exposedPort] = struct{}{} + + external, extOk := port["external"].(int) + ip, ipOk := port["ip"].(string) + + if extOk { + portBinding := dc.PortBinding{ + HostPort: strconv.Itoa(external), + } + if ipOk { + portBinding.HostIP = ip + } + retPortBindings[exposedPort] = append(retPortBindings[exposedPort], portBinding) + } + } + + return retExposedPorts, retPortBindings +} + +func extraHostsSetToDockerExtraHosts(extraHosts *schema.Set) []string { + retExtraHosts := []string{} + + for _, hostInt := range extraHosts.List() { + host := hostInt.(map[string]interface{}) + ip := host["ip"].(string) + hostname := host["host"].(string) + retExtraHosts = append(retExtraHosts, hostname+":"+ip) + } + + return retExtraHosts +} + +func volumeSetToDockerVolumes(volumes *schema.Set) (map[string]struct{}, []string, []string, error) { + retVolumeMap := map[string]struct{}{} + retHostConfigBinds := []string{} + retVolumeFromContainers := []string{} + + for _, volumeInt := range volumes.List() { + volume := volumeInt.(map[string]interface{}) + fromContainer := volume["from_container"].(string) + containerPath := volume["container_path"].(string) + volumeName := volume["volume_name"].(string) + if len(volumeName) == 0 { + volumeName = volume["host_path"].(string) + } + readOnly := volume["read_only"].(bool) + + switch { + case len(fromContainer) == 0 && len(containerPath) == 0: + return retVolumeMap, retHostConfigBinds, retVolumeFromContainers, errors.New("Volume entry without container path or source container") + case len(fromContainer) != 0 && len(containerPath) != 0: + return retVolumeMap, retHostConfigBinds, retVolumeFromContainers, errors.New("Both a container and a path specified in a volume entry") + case len(fromContainer) != 0: + retVolumeFromContainers = append(retVolumeFromContainers, fromContainer) + case len(volumeName) != 0: + readWrite := "rw" + if readOnly { + readWrite = "ro" + } + retVolumeMap[containerPath] = struct{}{} + retHostConfigBinds = append(retHostConfigBinds, volumeName+":"+containerPath+":"+readWrite) + default: + retVolumeMap[containerPath] = struct{}{} + } + } + + return retVolumeMap, retHostConfigBinds, retVolumeFromContainers, nil +} diff --git a/docker/resource_docker_container_test.go b/docker/resource_docker_container_test.go new file mode 100644 index 00000000..dcc6affa --- /dev/null +++ b/docker/resource_docker_container_test.go @@ -0,0 +1,410 @@ +package docker + +import ( + "archive/tar" + "bytes" + "fmt" + "testing" + + dc "github.com/fsouza/go-dockerclient" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +func TestAccDockerContainer_basic(t *testing.T) { + var c dc.Container + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccDockerContainerConfig, + Check: resource.ComposeTestCheckFunc( + testAccContainerRunning("docker_container.foo", &c), + ), + }, + }, + }) +} + +func TestAccDockerContainerPath_validation(t *testing.T) { + cases := []struct { + Value string + ErrCount int + }{ + {Value: "/var/log", ErrCount: 0}, + {Value: "/tmp", ErrCount: 0}, + {Value: "C:\\Windows\\System32", ErrCount: 0}, + {Value: "C:\\Program Files\\MSBuild", ErrCount: 0}, + {Value: "test", ErrCount: 1}, + {Value: "C:Test", ErrCount: 1}, + {Value: "", ErrCount: 1}, + } + + for _, tc := range cases { + _, errors := validateDockerContainerPath(tc.Value, "docker_container") + + if len(errors) != tc.ErrCount { + t.Fatalf("Expected the Docker Container Path to trigger a validation error") + } + } +} + +func TestAccDockerContainer_volume(t *testing.T) { + var c dc.Container + + testCheck := func(*terraform.State) error { + if len(c.Mounts) != 1 { + return fmt.Errorf("Incorrect number of mounts: expected 1, got %d", len(c.Mounts)) + } + + for _, v := range c.Mounts { + if v.Name != "testAccDockerContainerVolume_volume" { + continue + } + + if v.Destination != "/tmp/volume" { + return fmt.Errorf("Bad destination on mount: expected /tmp/volume, got %q", v.Destination) + } + + if v.Mode != "rw" { + return fmt.Errorf("Bad mode on mount: expected rw, got %q", v.Mode) + } + + return nil + } + + return fmt.Errorf("Mount for testAccDockerContainerVolume_volume not found") + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccDockerContainerVolumeConfig, + Check: resource.ComposeTestCheckFunc( + testAccContainerRunning("docker_container.foo", &c), + testCheck, + ), + }, + }, + }) +} + +func TestAccDockerContainer_customized(t *testing.T) { + var c dc.Container + + testCheck := func(*terraform.State) error { + if len(c.Config.Entrypoint) < 3 || + (c.Config.Entrypoint[0] != "/bin/bash" && + c.Config.Entrypoint[1] != "-c" && + c.Config.Entrypoint[2] != "ping localhost") { + return fmt.Errorf("Container wrong entrypoint: %s", c.Config.Entrypoint) + } + + if c.Config.User != "root:root" { + return fmt.Errorf("Container wrong user: %s", c.Config.User) + } + + if c.HostConfig.RestartPolicy.Name == "on-failure" { + if c.HostConfig.RestartPolicy.MaximumRetryCount != 5 { + return fmt.Errorf("Container has wrong restart policy max retry count: %d", c.HostConfig.RestartPolicy.MaximumRetryCount) + } + } else { + return fmt.Errorf("Container has wrong restart policy: %s", c.HostConfig.RestartPolicy.Name) + } + + if c.HostConfig.Memory != (512 * 1024 * 1024) { + return fmt.Errorf("Container has wrong memory setting: %d", c.HostConfig.Memory) + } + + if c.HostConfig.MemorySwap != (2048 * 1024 * 1024) { + return fmt.Errorf("Container has wrong memory swap setting: %d\n\r\tPlease check that you machine supports memory swap (you can do that by running 'docker info' command).", c.HostConfig.MemorySwap) + } + + if c.HostConfig.CPUShares != 32 { + return fmt.Errorf("Container has wrong cpu shares setting: %d", c.HostConfig.CPUShares) + } + + if len(c.HostConfig.DNS) != 1 { + return fmt.Errorf("Container does not have the correct number of dns entries: %d", len(c.HostConfig.DNS)) + } + + if c.HostConfig.DNS[0] != "8.8.8.8" { + return fmt.Errorf("Container has wrong dns setting: %v", c.HostConfig.DNS[0]) + } + + if len(c.HostConfig.DNSOptions) != 1 { + return fmt.Errorf("Container does not have the correct number of dns option entries: %d", len(c.HostConfig.DNS)) + } + + if c.HostConfig.DNSOptions[0] != "rotate" { + return fmt.Errorf("Container has wrong dns option setting: %v", c.HostConfig.DNS[0]) + } + + if len(c.HostConfig.DNSSearch) != 1 { + return fmt.Errorf("Container does not have the correct number of dns search entries: %d", len(c.HostConfig.DNS)) + } + + if c.HostConfig.DNSSearch[0] != "example.com" { + return fmt.Errorf("Container has wrong dns search setting: %v", c.HostConfig.DNS[0]) + } + + if len(c.HostConfig.CapAdd) != 1 { + return fmt.Errorf("Container does not have the correct number of Capabilities in ADD: %d", len(c.HostConfig.CapAdd)) + } + + if c.HostConfig.CapAdd[0] != "ALL" { + return fmt.Errorf("Container has wrong CapAdd setting: %v", c.HostConfig.CapAdd[0]) + } + + if len(c.HostConfig.CapDrop) != 1 { + return fmt.Errorf("Container does not have the correct number of Capabilities in Drop: %d", len(c.HostConfig.CapDrop)) + } + + if c.HostConfig.CapDrop[0] != "SYS_ADMIN" { + return fmt.Errorf("Container has wrong CapDrop setting: %v", c.HostConfig.CapDrop[0]) + } + + if c.HostConfig.CPUShares != 32 { + return fmt.Errorf("Container has wrong cpu shares setting: %d", c.HostConfig.CPUShares) + } + + if c.HostConfig.CPUShares != 32 { + return fmt.Errorf("Container has wrong cpu shares setting: %d", c.HostConfig.CPUShares) + } + + if c.Config.Labels["env"] != "prod" || c.Config.Labels["role"] != "test" { + return fmt.Errorf("Container does not have the correct labels") + } + + if c.HostConfig.LogConfig.Type != "json-file" { + return fmt.Errorf("Container does not have the correct log config: %s", c.HostConfig.LogConfig.Type) + } + + if c.HostConfig.LogConfig.Config["max-size"] != "10m" { + return fmt.Errorf("Container does not have the correct max-size log option: %v", c.HostConfig.LogConfig.Config["max-size"]) + } + + if c.HostConfig.LogConfig.Config["max-file"] != "20" { + return fmt.Errorf("Container does not have the correct max-file log option: %v", c.HostConfig.LogConfig.Config["max-file"]) + } + + if len(c.HostConfig.ExtraHosts) != 2 { + return fmt.Errorf("Container does not have correct number of extra host entries, got %d", len(c.HostConfig.ExtraHosts)) + } + + if c.HostConfig.ExtraHosts[0] != "testhost2:10.0.2.0" { + return fmt.Errorf("Container has incorrect extra host string: %q", c.HostConfig.ExtraHosts[0]) + } + + if c.HostConfig.ExtraHosts[1] != "testhost:10.0.1.0" { + return fmt.Errorf("Container has incorrect extra host string: %q", c.HostConfig.ExtraHosts[1]) + } + + if _, ok := c.NetworkSettings.Networks["test"]; !ok { + return fmt.Errorf("Container is not connected to the right user defined network: test") + } + + return nil + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccDockerContainerCustomizedConfig, + Check: resource.ComposeTestCheckFunc( + testAccContainerRunning("docker_container.foo", &c), + testCheck, + ), + }, + }, + }) +} + +func TestAccDockerContainer_upload(t *testing.T) { + var c dc.Container + + testCheck := func(*terraform.State) error { + client := testAccProvider.Meta().(*dc.Client) + + buf := new(bytes.Buffer) + opts := dc.DownloadFromContainerOptions{ + OutputStream: buf, + Path: "/terraform/test.txt", + } + + if err := client.DownloadFromContainer(c.ID, opts); err != nil { + return fmt.Errorf("Unable to download a file from container: %s", err) + } + + r := bytes.NewReader(buf.Bytes()) + tr := tar.NewReader(r) + + if _, err := tr.Next(); err != nil { + return fmt.Errorf("Unable to read content of tar archive: %s", err) + } + + fbuf := new(bytes.Buffer) + fbuf.ReadFrom(tr) + content := fbuf.String() + + if content != "foo" { + return fmt.Errorf("file content is invalid") + } + + return nil + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccDockerContainerUploadConfig, + Check: resource.ComposeTestCheckFunc( + testAccContainerRunning("docker_container.foo", &c), + testCheck, + ), + }, + }, + }) +} + +func testAccContainerRunning(n string, container *dc.Container) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No ID is set") + } + + client := testAccProvider.Meta().(*dc.Client) + containers, err := client.ListContainers(dc.ListContainersOptions{}) + if err != nil { + return err + } + + for _, c := range containers { + if c.ID == rs.Primary.ID { + inspected, err := client.InspectContainer(c.ID) + if err != nil { + return fmt.Errorf("Container could not be inspected: %s", err) + } + *container = *inspected + return nil + } + } + + return fmt.Errorf("Container not found: %s", rs.Primary.ID) + } +} + +const testAccDockerContainerConfig = ` +resource "docker_image" "foo" { + name = "nginx:latest" +} + +resource "docker_container" "foo" { + name = "tf-test" + image = "${docker_image.foo.latest}" +} +` + +const testAccDockerContainerVolumeConfig = ` +resource "docker_image" "foo" { + name = "nginx:latest" +} + +resource "docker_volume" "foo" { + name = "testAccDockerContainerVolume_volume" +} + +resource "docker_container" "foo" { + name = "tf-test" + image = "${docker_image.foo.latest}" + + volumes { + volume_name = "${docker_volume.foo.name}" + container_path = "/tmp/volume" + read_only = false + } +} +` + +const testAccDockerContainerCustomizedConfig = ` +resource "docker_image" "foo" { + name = "nginx:latest" +} + +resource "docker_container" "foo" { + name = "tf-test" + image = "${docker_image.foo.latest}" + entrypoint = ["/bin/bash", "-c", "ping localhost"] + user = "root:root" + restart = "on-failure" + destroy_grace_seconds = 10 + max_retry_count = 5 + memory = 512 + memory_swap = 2048 + cpu_shares = 32 + + capabilities { + add= ["ALL"] + drop = ["SYS_ADMIN"] + } + + dns = ["8.8.8.8"] + dns_opts = ["rotate"] + dns_search = ["example.com"] + labels { + env = "prod" + role = "test" + } + log_driver = "json-file" + log_opts = { + max-size = "10m" + max-file = 20 + } + network_mode = "bridge" + + networks = ["${docker_network.test_network.name}"] + network_alias = ["tftest"] + + host { + host = "testhost" + ip = "10.0.1.0" + } + + host { + host = "testhost2" + ip = "10.0.2.0" + } +} + +resource "docker_network" "test_network" { + name = "test" +} +` + +const testAccDockerContainerUploadConfig = ` +resource "docker_image" "foo" { + name = "nginx:latest" +} + +resource "docker_container" "foo" { + name = "tf-test" + image = "${docker_image.foo.latest}" + + upload { + content = "foo" + file = "/terraform/test.txt" + } +} +` diff --git a/docker/resource_docker_image.go b/docker/resource_docker_image.go new file mode 100644 index 00000000..eb84a257 --- /dev/null +++ b/docker/resource_docker_image.go @@ -0,0 +1,47 @@ +package docker + +import ( + "github.com/hashicorp/terraform/helper/schema" +) + +func resourceDockerImage() *schema.Resource { + return &schema.Resource{ + Create: resourceDockerImageCreate, + Read: resourceDockerImageRead, + Update: resourceDockerImageUpdate, + Delete: resourceDockerImageDelete, + + Schema: map[string]*schema.Schema{ + "name": &schema.Schema{ + Type: schema.TypeString, + Required: true, + }, + + "latest": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + }, + + "keep_locally": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + }, + + "pull_trigger": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ConflictsWith: []string{"pull_triggers"}, + Deprecated: "Use field pull_triggers instead", + }, + + "pull_triggers": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + ForceNew: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Set: schema.HashString, + }, + }, + } +} diff --git a/docker/resource_docker_image_funcs.go b/docker/resource_docker_image_funcs.go new file mode 100644 index 00000000..9c27b425 --- /dev/null +++ b/docker/resource_docker_image_funcs.go @@ -0,0 +1,201 @@ +package docker + +import ( + "fmt" + "strings" + + dc "github.com/fsouza/go-dockerclient" + "github.com/hashicorp/terraform/helper/schema" +) + +func resourceDockerImageCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*dc.Client) + apiImage, err := findImage(d, client) + if err != nil { + return fmt.Errorf("Unable to read Docker image into resource: %s", err) + } + + d.SetId(apiImage.ID + d.Get("name").(string)) + d.Set("latest", apiImage.ID) + + return nil +} + +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 { + return fmt.Errorf("Unable to read Docker image into resource: %s", err) + } + + d.Set("latest", apiImage.ID) + + return nil +} + +func resourceDockerImageDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*dc.Client) + err := removeImage(d, client) + if err != nil { + return fmt.Errorf("Unable to remove Docker image: %s", err) + } + d.SetId("") + return nil +} + +func searchLocalImages(data Data, imageName string) *dc.APIImages { + if apiImage, ok := data.DockerImages[imageName]; ok { + return apiImage + } + if apiImage, ok := data.DockerImages[imageName+":latest"]; ok { + imageName = imageName + ":latest" + return apiImage + } + return nil +} + +func removeImage(d *schema.ResourceData, client *dc.Client) error { + var data Data + + if keepLocally := d.Get("keep_locally").(bool); keepLocally { + return nil + } + + if err := fetchLocalImages(&data, client); err != nil { + return err + } + + imageName := d.Get("name").(string) + if imageName == "" { + return fmt.Errorf("Empty image name is not allowed") + } + + foundImage := searchLocalImages(data, imageName) + + if foundImage != nil { + err := client.RemoveImage(foundImage.ID) + if err != nil { + return err + } + } + + return nil +} + +func fetchLocalImages(data *Data, client *dc.Client) error { + images, err := client.ListImages(dc.ListImagesOptions{All: false}) + if err != nil { + return fmt.Errorf("Unable to list Docker images: %s", err) + } + + if data.DockerImages == nil { + data.DockerImages = make(map[string]*dc.APIImages) + } + + // Docker uses different nomenclatures in different places...sometimes a short + // ID, sometimes long, etc. So we store both in the map so we can always find + // the same image object. We store the tags, too. + for i, image := range images { + data.DockerImages[image.ID[:12]] = &images[i] + data.DockerImages[image.ID] = &images[i] + for _, repotag := range image.RepoTags { + data.DockerImages[repotag] = &images[i] + } + } + + return nil +} + +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, ":") + switch len(splitImageName) { + + // It's in registry:port/username/repo:tag or registry:port/repo:tag format + case 3: + splitPortRepo := strings.Split(splitImageName[1], "/") + pullOpts.Registry = splitImageName[0] + ":" + splitPortRepo[0] + pullOpts.Tag = splitImageName[2] + pullOpts.Repository = pullOpts.Registry + "/" + strings.Join(splitPortRepo[1:], "/") + + // It's either registry:port/username/repo, registry:port/repo, + // or repo:tag with default registry + case 2: + splitPortRepo := strings.Split(splitImageName[1], "/") + switch len(splitPortRepo) { + // repo:tag + case 1: + pullOpts.Repository = splitImageName[0] + pullOpts.Tag = splitImageName[1] + + // registry:port/username/repo or registry:port/repo + default: + pullOpts.Registry = splitImageName[0] + ":" + splitPortRepo[0] + pullOpts.Repository = pullOpts.Registry + "/" + strings.Join(splitPortRepo[1:], "/") + pullOpts.Tag = "latest" + } + + // Plain username/repo or repo + default: + pullOpts.Repository = image + } + + return pullOpts +} + +func findImage(d *schema.ResourceData, client *dc.Client) (*dc.APIImages, error) { + var data Data + if err := fetchLocalImages(&data, client); err != nil { + return nil, err + } + + imageName := d.Get("name").(string) + if imageName == "" { + return nil, fmt.Errorf("Empty image name is not allowed") + } + + if err := pullImage(&data, client, imageName); err != nil { + return nil, fmt.Errorf("Unable to pull image %s: %s", imageName, err) + } + + foundImage := searchLocalImages(data, imageName) + if foundImage != nil { + return foundImage, nil + } + + return nil, fmt.Errorf("Unable to find or pull image %s", imageName) +} diff --git a/docker/resource_docker_image_test.go b/docker/resource_docker_image_test.go new file mode 100644 index 00000000..4d75a617 --- /dev/null +++ b/docker/resource_docker_image_test.go @@ -0,0 +1,162 @@ +package docker + +import ( + "fmt" + "regexp" + "testing" + + dc "github.com/fsouza/go-dockerclient" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +var contentDigestRegexp = regexp.MustCompile(`\A[A-Za-z0-9_\+\.-]+:[A-Fa-f0-9]+\z`) + +func TestAccDockerImage_basic(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccDockerImageDestroy, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccDockerImageConfig, + Check: resource.ComposeTestCheckFunc( + resource.TestMatchResourceAttr("docker_image.foo", "latest", contentDigestRegexp), + ), + }, + }, + }) +} + +func TestAccDockerImage_private(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccDockerImageDestroy, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAddDockerPrivateImageConfig, + Check: resource.ComposeTestCheckFunc( + resource.TestMatchResourceAttr("docker_image.foobar", "latest", contentDigestRegexp), + ), + }, + }, + }) +} + +func TestAccDockerImage_destroy(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: func(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "docker_image" { + continue + } + + client := testAccProvider.Meta().(*dc.Client) + _, err := client.InspectImage(rs.Primary.Attributes["latest"]) + if err != nil { + return err + } + } + return nil + }, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccDockerImageKeepLocallyConfig, + Check: resource.ComposeTestCheckFunc( + resource.TestMatchResourceAttr("docker_image.foobarzoo", "latest", contentDigestRegexp), + ), + }, + }, + }) +} + +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 TestAccDockerImage_data_pull_trigger(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + PreventPostDestroyRefresh: true, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccDockerImageFromDataConfigWithPullTrigger, + Check: resource.ComposeTestCheckFunc( + resource.TestMatchResourceAttr("docker_image.foobarbazoo", "latest", contentDigestRegexp), + ), + }, + }, + }) +} + +func testAccDockerImageDestroy(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "docker_image" { + continue + } + + client := testAccProvider.Meta().(*dc.Client) + _, err := client.InspectImage(rs.Primary.Attributes["latest"]) + if err == nil { + return fmt.Errorf("Image still exists") + } else if err != dc.ErrNoSuchImage { + return err + } + } + return nil +} + +const testAccDockerImageConfig = ` +resource "docker_image" "foo" { + name = "alpine:3.1" +} +` + +const testAddDockerPrivateImageConfig = ` +resource "docker_image" "foobar" { + name = "gcr.io:443/google_containers/pause:0.8.0" +} +` + +const testAccDockerImageKeepLocallyConfig = ` +resource "docker_image" "foobarzoo" { + name = "crux:3.1" + 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_triggers = ["${data.docker_registry_image.foobarbaz.sha256_digest}"] +} +` + +const testAccDockerImageFromDataConfigWithPullTrigger = ` +data "docker_registry_image" "foobarbazoo" { + name = "alpine:3.1" +} +resource "docker_image" "foobarbazoo" { + name = "${data.docker_registry_image.foobarbazoo.name}" + pull_trigger = "${data.docker_registry_image.foobarbazoo.sha256_digest}" +} +` diff --git a/docker/resource_docker_network.go b/docker/resource_docker_network.go new file mode 100644 index 00000000..7279d2ee --- /dev/null +++ b/docker/resource_docker_network.go @@ -0,0 +1,142 @@ +package docker + +import ( + "bytes" + "fmt" + "sort" + + "github.com/hashicorp/terraform/helper/hashcode" + "github.com/hashicorp/terraform/helper/schema" +) + +func resourceDockerNetwork() *schema.Resource { + return &schema.Resource{ + Create: resourceDockerNetworkCreate, + Read: resourceDockerNetworkRead, + Delete: resourceDockerNetworkDelete, + + Schema: map[string]*schema.Schema{ + "name": &schema.Schema{ + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + + "check_duplicate": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + ForceNew: true, + }, + + "driver": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Computed: true, + }, + + "options": &schema.Schema{ + Type: schema.TypeMap, + Optional: true, + ForceNew: true, + Computed: true, + }, + + "internal": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + Computed: true, + ForceNew: true, + }, + + "ipam_driver": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + + "ipam_config": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + ForceNew: true, + Elem: getIpamConfigElem(), + Set: resourceDockerIpamConfigHash, + }, + + "id": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + }, + + "scope": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func getIpamConfigElem() *schema.Resource { + return &schema.Resource{ + Schema: map[string]*schema.Schema{ + "subnet": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + + "ip_range": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + + "gateway": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + + "aux_address": &schema.Schema{ + Type: schema.TypeMap, + Optional: true, + ForceNew: true, + }, + }, + } +} + +func resourceDockerIpamConfigHash(v interface{}) int { + var buf bytes.Buffer + m := v.(map[string]interface{}) + + if v, ok := m["subnet"]; ok { + buf.WriteString(fmt.Sprintf("%v-", v.(string))) + } + + if v, ok := m["ip_range"]; ok { + buf.WriteString(fmt.Sprintf("%v-", v.(string))) + } + + if v, ok := m["gateway"]; ok { + buf.WriteString(fmt.Sprintf("%v-", v.(string))) + } + + if v, ok := m["aux_address"]; ok { + auxAddress := v.(map[string]interface{}) + + keys := make([]string, len(auxAddress)) + i := 0 + for k, _ := range auxAddress { + keys[i] = k + i++ + } + sort.Strings(keys) + + for _, k := range keys { + buf.WriteString(fmt.Sprintf("%v-%v-", k, auxAddress[k].(string))) + } + } + + return hashcode.String(buf.String()) +} diff --git a/docker/resource_docker_network_funcs.go b/docker/resource_docker_network_funcs.go new file mode 100644 index 00000000..f5ff172b --- /dev/null +++ b/docker/resource_docker_network_funcs.go @@ -0,0 +1,122 @@ +package docker + +import ( + "fmt" + + dc "github.com/fsouza/go-dockerclient" + "github.com/hashicorp/terraform/helper/schema" +) + +func resourceDockerNetworkCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*dc.Client) + + createOpts := dc.CreateNetworkOptions{ + Name: d.Get("name").(string), + } + if v, ok := d.GetOk("check_duplicate"); ok { + createOpts.CheckDuplicate = v.(bool) + } + if v, ok := d.GetOk("driver"); ok { + createOpts.Driver = v.(string) + } + if v, ok := d.GetOk("options"); ok { + createOpts.Options = v.(map[string]interface{}) + } + if v, ok := d.GetOk("internal"); ok { + createOpts.Internal = v.(bool) + } + + ipamOpts := dc.IPAMOptions{} + ipamOptsSet := false + if v, ok := d.GetOk("ipam_driver"); ok { + ipamOpts.Driver = v.(string) + ipamOptsSet = true + } + if v, ok := d.GetOk("ipam_config"); ok { + ipamOpts.Config = ipamConfigSetToIpamConfigs(v.(*schema.Set)) + ipamOptsSet = true + } + + if ipamOptsSet { + createOpts.IPAM = ipamOpts + } + + var err error + var retNetwork *dc.Network + if retNetwork, err = client.CreateNetwork(createOpts); err != nil { + return fmt.Errorf("Unable to create network: %s", err) + } + if retNetwork == nil { + return fmt.Errorf("Returned network is nil") + } + + d.SetId(retNetwork.ID) + d.Set("name", retNetwork.Name) + d.Set("scope", retNetwork.Scope) + d.Set("driver", retNetwork.Driver) + d.Set("options", retNetwork.Options) + + // The 'internal' property is not send back when create network + d.Set("internal", createOpts.Internal) + + return nil +} + +func resourceDockerNetworkRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*dc.Client) + + var err error + var retNetwork *dc.Network + if retNetwork, err = client.NetworkInfo(d.Id()); err != nil { + if _, ok := err.(*dc.NoSuchNetwork); !ok { + return fmt.Errorf("Unable to inspect network: %s", err) + } + } + if retNetwork == nil { + d.SetId("") + return nil + } + + d.Set("scope", retNetwork.Scope) + d.Set("driver", retNetwork.Driver) + d.Set("options", retNetwork.Options) + d.Set("internal", retNetwork.Internal) + + return nil +} + +func resourceDockerNetworkDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*dc.Client) + + if err := client.RemoveNetwork(d.Id()); err != nil { + if _, ok := err.(*dc.NoSuchNetwork); !ok { + return fmt.Errorf("Error deleting network %s: %s", d.Id(), err) + } + } + + d.SetId("") + return nil +} + +func ipamConfigSetToIpamConfigs(ipamConfigSet *schema.Set) []dc.IPAMConfig { + ipamConfigs := make([]dc.IPAMConfig, ipamConfigSet.Len()) + + for i, ipamConfigInt := range ipamConfigSet.List() { + ipamConfigRaw := ipamConfigInt.(map[string]interface{}) + + ipamConfig := dc.IPAMConfig{} + ipamConfig.Subnet = ipamConfigRaw["subnet"].(string) + ipamConfig.IPRange = ipamConfigRaw["ip_range"].(string) + ipamConfig.Gateway = ipamConfigRaw["gateway"].(string) + + auxAddressRaw := ipamConfigRaw["aux_address"].(map[string]interface{}) + ipamConfig.AuxAddress = make(map[string]string, len(auxAddressRaw)) + for k, v := range auxAddressRaw { + ipamConfig.AuxAddress[k] = v.(string) + } + + ipamConfigs[i] = ipamConfig + } + + return ipamConfigs +} diff --git a/docker/resource_docker_network_test.go b/docker/resource_docker_network_test.go new file mode 100644 index 00000000..5fe7f8b3 --- /dev/null +++ b/docker/resource_docker_network_test.go @@ -0,0 +1,99 @@ +package docker + +import ( + "fmt" + "testing" + + dc "github.com/fsouza/go-dockerclient" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +func TestAccDockerNetwork_basic(t *testing.T) { + var n dc.Network + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccDockerNetworkConfig, + Check: resource.ComposeTestCheckFunc( + testAccNetwork("docker_network.foo", &n), + ), + }, + }, + }) +} + +func testAccNetwork(n string, network *dc.Network) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No ID is set") + } + + client := testAccProvider.Meta().(*dc.Client) + networks, err := client.ListNetworks() + if err != nil { + return err + } + + for _, n := range networks { + if n.ID == rs.Primary.ID { + inspected, err := client.NetworkInfo(n.ID) + if err != nil { + return fmt.Errorf("Network could not be obtained: %s", err) + } + *network = *inspected + return nil + } + } + + return fmt.Errorf("Network not found: %s", rs.Primary.ID) + } +} + +const testAccDockerNetworkConfig = ` +resource "docker_network" "foo" { + name = "bar" +} +` + +func TestAccDockerNetwork_internal(t *testing.T) { + var n dc.Network + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccDockerNetworkInternalConfig, + Check: resource.ComposeTestCheckFunc( + testAccNetwork("docker_network.foobar", &n), + testAccNetworkInternal(&n, true), + ), + }, + }, + }) +} + +func testAccNetworkInternal(network *dc.Network, internal bool) resource.TestCheckFunc { + return func(s *terraform.State) error { + if network.Internal != internal { + return fmt.Errorf("Bad value for attribute 'internal': %t", network.Internal) + } + return nil + } +} + +const testAccDockerNetworkInternalConfig = ` +resource "docker_network" "foobar" { + name = "foobar" + internal = "true" +} +` diff --git a/docker/resource_docker_volume.go b/docker/resource_docker_volume.go new file mode 100644 index 00000000..33c22d58 --- /dev/null +++ b/docker/resource_docker_volume.go @@ -0,0 +1,102 @@ +package docker + +import ( + "fmt" + + dc "github.com/fsouza/go-dockerclient" + "github.com/hashicorp/terraform/helper/schema" +) + +func resourceDockerVolume() *schema.Resource { + return &schema.Resource{ + Create: resourceDockerVolumeCreate, + Read: resourceDockerVolumeRead, + Delete: resourceDockerVolumeDelete, + + Schema: map[string]*schema.Schema{ + "name": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Computed: true, + ForceNew: true, + }, + "driver": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Computed: true, + ForceNew: true, + }, + "driver_opts": &schema.Schema{ + Type: schema.TypeMap, + Optional: true, + ForceNew: true, + }, + "mountpoint": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func resourceDockerVolumeCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*dc.Client) + + createOpts := dc.CreateVolumeOptions{} + if v, ok := d.GetOk("name"); ok { + createOpts.Name = v.(string) + } + if v, ok := d.GetOk("driver"); ok { + createOpts.Driver = v.(string) + } + if v, ok := d.GetOk("driver_opts"); ok { + createOpts.DriverOpts = mapTypeMapValsToString(v.(map[string]interface{})) + } + + var err error + var retVolume *dc.Volume + if retVolume, err = client.CreateVolume(createOpts); err != nil { + return fmt.Errorf("Unable to create volume: %s", err) + } + if retVolume == nil { + return fmt.Errorf("Returned volume is nil") + } + + d.SetId(retVolume.Name) + d.Set("name", retVolume.Name) + d.Set("driver", retVolume.Driver) + d.Set("mountpoint", retVolume.Mountpoint) + + return nil +} + +func resourceDockerVolumeRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*dc.Client) + + var err error + var retVolume *dc.Volume + if retVolume, err = client.InspectVolume(d.Id()); err != nil && err != dc.ErrNoSuchVolume { + return fmt.Errorf("Unable to inspect volume: %s", err) + } + if retVolume == nil { + d.SetId("") + return nil + } + + d.Set("name", retVolume.Name) + d.Set("driver", retVolume.Driver) + d.Set("mountpoint", retVolume.Mountpoint) + + return nil +} + +func resourceDockerVolumeDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*dc.Client) + + if err := client.RemoveVolume(d.Id()); err != nil && err != dc.ErrNoSuchVolume { + return fmt.Errorf("Error deleting volume %s: %s", d.Id(), err) + } + + d.SetId("") + return nil +} diff --git a/docker/resource_docker_volume_test.go b/docker/resource_docker_volume_test.go new file mode 100644 index 00000000..38fec3c4 --- /dev/null +++ b/docker/resource_docker_volume_test.go @@ -0,0 +1,67 @@ +package docker + +import ( + "fmt" + "testing" + + dc "github.com/fsouza/go-dockerclient" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +func TestAccDockerVolume_basic(t *testing.T) { + var v dc.Volume + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccDockerVolumeConfig, + Check: resource.ComposeTestCheckFunc( + checkDockerVolume("docker_volume.foo", &v), + resource.TestCheckResourceAttr("docker_volume.foo", "id", "testAccDockerVolume_basic"), + resource.TestCheckResourceAttr("docker_volume.foo", "name", "testAccDockerVolume_basic"), + ), + }, + }, + }) +} + +func checkDockerVolume(n string, volume *dc.Volume) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No ID is set") + } + + client := testAccProvider.Meta().(*dc.Client) + volumes, err := client.ListVolumes(dc.ListVolumesOptions{}) + if err != nil { + return err + } + + for _, v := range volumes { + if v.Name == rs.Primary.ID { + inspected, err := client.InspectVolume(v.Name) + if err != nil { + return fmt.Errorf("Volume could not be inspected: %s", err) + } + *volume = *inspected + return nil + } + } + + return fmt.Errorf("Volume not found: %s", rs.Primary.ID) + } +} + +const testAccDockerVolumeConfig = ` +resource "docker_volume" "foo" { + name = "testAccDockerVolume_basic" +} +`