diff --git a/builtin/bins/provider-digitalocean/main.go b/builtin/bins/provider-digitalocean/main.go new file mode 100644 index 000000000..3a4d2c46c --- /dev/null +++ b/builtin/bins/provider-digitalocean/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/hashicorp/terraform/builtin/providers/digitalocean" + "github.com/hashicorp/terraform/plugin" +) + +func main() { + plugin.Serve(new(digitalocean.ResourceProvider)) +} diff --git a/builtin/bins/provider-digitalocean/main_test.go b/builtin/bins/provider-digitalocean/main_test.go new file mode 100644 index 000000000..06ab7d0f9 --- /dev/null +++ b/builtin/bins/provider-digitalocean/main_test.go @@ -0,0 +1 @@ +package main diff --git a/builtin/providers/digitalocean/config.go b/builtin/providers/digitalocean/config.go new file mode 100644 index 000000000..8e1df5389 --- /dev/null +++ b/builtin/providers/digitalocean/config.go @@ -0,0 +1,26 @@ +package digitalocean + +import ( + "log" + + "github.com/pearkes/digitalocean" +) + +type Config struct { + Token string `mapstructure:"token"` +} + +// Client() returns a new client for accessing digital +// ocean. +// +func (c *Config) Client() (*digitalocean.Client, error) { + client, err := digitalocean.NewClient(c.Token) + + log.Printf("[INFO] DigitalOcean Client configured for URL: %s", client.URL) + + if err != nil { + return nil, err + } + + return client, nil +} diff --git a/builtin/providers/digitalocean/resource_digitalocean_droplet.go b/builtin/providers/digitalocean/resource_digitalocean_droplet.go new file mode 100644 index 000000000..49f800953 --- /dev/null +++ b/builtin/providers/digitalocean/resource_digitalocean_droplet.go @@ -0,0 +1,244 @@ +package digitalocean + +import ( + "fmt" + "log" + "strings" + "time" + + "github.com/hashicorp/terraform/flatmap" + "github.com/hashicorp/terraform/helper/config" + "github.com/hashicorp/terraform/helper/diff" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" + "github.com/pearkes/digitalocean" +) + +func resource_digitalocean_droplet_create( + s *terraform.ResourceState, + d *terraform.ResourceDiff, + meta interface{}) (*terraform.ResourceState, error) { + p := meta.(*ResourceProvider) + client := p.client + + // Merge the diff into the state so that we have all the attributes + // properly. + rs := s.MergeDiff(d) + + // Build up our creation options + opts := digitalocean.CreateDroplet{ + Name: rs.Attributes["name"], + Region: rs.Attributes["region"], + Image: rs.Attributes["image"], + Size: rs.Attributes["size"], + Backups: rs.Attributes["backups"], + IPV6: rs.Attributes["ipv6"], + PrivateNetworking: rs.Attributes["private_networking"], + } + + // Only expand ssh_keys if we have them + if _, ok := rs.Attributes["ssh_keys.#"]; ok { + v := flatmap.Expand(rs.Attributes, "ssh_keys").([]interface{}) + if len(v) > 0 { + vs := make([]string, 0, len(v)) + + // here we special case the * expanded lists. For example: + // + // ssh_keys = ["${digitalocean_key.foo.*.id}"] + // + if len(v) == 1 && strings.Contains(v[0].(string), ",") { + vs = strings.Split(v[0].(string), ",") + } + + for _, v := range v { + vs = append(vs, v.(string)) + } + + opts.SSHKeys = vs + } + } + + log.Printf("[DEBUG] Droplet create configuration: %#v", opts) + + id, err := client.CreateDroplet(&opts) + + if err != nil { + return nil, fmt.Errorf("Error creating Droplet: %s", err) + } + + // Assign the droplets id + rs.ID = id + + log.Printf("[INFO] Droplet ID: %s", id) + + // Wait for the droplet so we can get the networking attributes + // that show up after a while + log.Printf( + "[DEBUG] Waiting for Droplet (%s) to become running", + id) + + stateConf := &resource.StateChangeConf{ + Pending: []string{"new"}, + Target: "active", + Refresh: DropletStateRefreshFunc(client, id), + Timeout: 10 * time.Minute, + } + + dropletRaw, err := stateConf.WaitForState() + + if err != nil { + return rs, fmt.Errorf( + "Error waiting for droplet (%s) to become ready: %s", + id, err) + } + + droplet := dropletRaw.(*digitalocean.Droplet) + + return resource_digitalocean_droplet_update_state(rs, droplet) +} + +func resource_digitalocean_droplet_update( + s *terraform.ResourceState, + d *terraform.ResourceDiff, + meta interface{}) (*terraform.ResourceState, error) { + + panic("No update") + + return nil, nil +} + +func resource_digitalocean_droplet_destroy( + s *terraform.ResourceState, + meta interface{}) error { + p := meta.(*ResourceProvider) + client := p.client + + log.Printf("[INFO] Deleting Droplet: %s", s.ID) + + // Destroy the droplet + err := client.DestroyDroplet(s.ID) + + if err != nil { + return fmt.Errorf("Error deleting Droplet: %s", err) + } + + return nil +} + +func resource_digitalocean_droplet_refresh( + s *terraform.ResourceState, + meta interface{}) (*terraform.ResourceState, error) { + p := meta.(*ResourceProvider) + client := p.client + + droplet, err := resource_digitalocean_droplet_retrieve(s.ID, client) + if err != nil { + return nil, err + } + + return resource_digitalocean_droplet_update_state(s, droplet) +} + +func resource_digitalocean_droplet_diff( + s *terraform.ResourceState, + c *terraform.ResourceConfig, + meta interface{}) (*terraform.ResourceDiff, error) { + + b := &diff.ResourceBuilder{ + Attrs: map[string]diff.AttrType{ + "name": diff.AttrTypeUpdate, + "backups": diff.AttrTypeUpdate, + "ipv6": diff.AttrTypeUpdate, + "private_networking": diff.AttrTypeUpdate, + "region": diff.AttrTypeCreate, + "image": diff.AttrTypeCreate, + "size": diff.AttrTypeCreate, + "ssh_keys": diff.AttrTypeCreate, + }, + + ComputedAttrs: []string{ + "ipv4_address", + "ipv6_address", + "status", + "locked", + "private_networking", + "ipv6", + "backups", + }, + } + + return b.Diff(s, c) +} + +func resource_digitalocean_droplet_update_state( + s *terraform.ResourceState, + droplet *digitalocean.Droplet) (*terraform.ResourceState, error) { + + s.Attributes["name"] = droplet.Name + s.Attributes["region"] = droplet.RegionSlug() + + if droplet.ImageSlug() == "" && droplet.ImageId() != "" { + s.Attributes["image"] = droplet.ImageId() + } else { + s.Attributes["image"] = droplet.ImageSlug() + } + + s.Attributes["size"] = droplet.SizeSlug() + s.Attributes["private_networking"] = droplet.NetworkingType() + s.Attributes["locked"] = droplet.IsLocked() + s.Attributes["status"] = droplet.Status + s.Attributes["ipv4_address"] = droplet.IPV4Address() + s.Attributes["ipv6_address"] = droplet.IPV6Address() + + return s, nil +} + +// retrieves an ELB by it's ID +func resource_digitalocean_droplet_retrieve(id string, client *digitalocean.Client) (*digitalocean.Droplet, error) { + // Retrieve the ELB properties for updating the state + droplet, err := client.RetrieveDroplet(id) + + if err != nil { + return nil, fmt.Errorf("Error retrieving droplet: %s", err) + } + + return &droplet, nil +} + +func resource_digitalocean_droplet_validation() *config.Validator { + return &config.Validator{ + Required: []string{ + "name", + "region", + "size", + "image", + }, + Optional: []string{ + "ssh_keys.*", + "backups", + "ipv6", + "private_networking", + }, + } +} + +// DropletStateRefreshFunc returns a resource.StateRefreshFunc that is used to watch +// a droplet. +func DropletStateRefreshFunc(client *digitalocean.Client, id string) resource.StateRefreshFunc { + return func() (interface{}, string, error) { + droplet, err := resource_digitalocean_droplet_retrieve(id, client) + + // It's not actually "active" + // until we can see the image slug + if droplet.ImageSlug() == "" { + return nil, "", nil + } + + if err != nil { + log.Printf("Error on DropletStateRefresh: %s", err) + return nil, "", err + } + + return droplet, droplet.Status, nil + } +} diff --git a/builtin/providers/digitalocean/resource_digitalocean_droplet_test.go b/builtin/providers/digitalocean/resource_digitalocean_droplet_test.go new file mode 100644 index 000000000..b66484745 --- /dev/null +++ b/builtin/providers/digitalocean/resource_digitalocean_droplet_test.go @@ -0,0 +1,130 @@ +package digitalocean + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" + "github.com/pearkes/digitalocean" +) + +func TestAccDigitalOceanDroplet(t *testing.T) { + var droplet digitalocean.Droplet + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckDigitalOceanDropletDestroy, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccCheckDigitalOceanDropletConfig_basic, + Check: resource.ComposeTestCheckFunc( + testAccCheckDigitalOceanDropletExists("digitalocean_droplet.foobar", &droplet), + testAccCheckDigitalOceanDropletAttributes(&droplet), + resource.TestCheckResourceAttr( + "digitalocean_droplet.foobar", "name", "foo"), + resource.TestCheckResourceAttr( + "digitalocean_droplet.foobar", "size", "512mb"), + resource.TestCheckResourceAttr( + "digitalocean_droplet.foobar", "image", "centos-5-8-x32"), + resource.TestCheckResourceAttr( + "digitalocean_droplet.foobar", "region", "nyc2"), + ), + }, + }, + }) +} + +func testAccCheckDigitalOceanDropletDestroy(s *terraform.State) error { + client := testAccProvider.client + + for _, rs := range s.Resources { + if rs.Type != "digitalocean_droplet" { + continue + } + + // Try to find the Droplet + _, err := client.RetrieveDroplet(rs.ID) + + stateConf := &resource.StateChangeConf{ + Pending: []string{"new"}, + Target: "off", + Refresh: DropletStateRefreshFunc(client, rs.ID), + Timeout: 10 * time.Minute, + } + + _, err = stateConf.WaitForState() + + if err != nil && !strings.Contains(err.Error(), "404") { + return fmt.Errorf( + "Error waiting for droplet (%s) to be destroyed: %s", + rs.ID, err) + } + } + + return nil +} + +func testAccCheckDigitalOceanDropletAttributes(droplet *digitalocean.Droplet) resource.TestCheckFunc { + return func(s *terraform.State) error { + + fmt.Println("droplet: %v", droplet) + if droplet.ImageSlug() != "centos-5-8-x32" { + return fmt.Errorf("Bad image_slug: %s", droplet.ImageSlug()) + } + + if droplet.SizeSlug() != "512mb" { + return fmt.Errorf("Bad size_slug: %s", droplet.SizeSlug()) + } + + if droplet.RegionSlug() != "nyc2" { + return fmt.Errorf("Bad region_slug: %s", droplet.RegionSlug()) + } + + if droplet.Name != "foo" { + return fmt.Errorf("Bad name: %s", droplet.Name) + } + return nil + } +} + +func testAccCheckDigitalOceanDropletExists(n string, droplet *digitalocean.Droplet) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.ID == "" { + return fmt.Errorf("No Droplet ID is set") + } + + client := testAccProvider.client + + retrieveDroplet, err := client.RetrieveDroplet(rs.ID) + + if err != nil { + return err + } + + if retrieveDroplet.StringId() != rs.ID { + return fmt.Errorf("Droplet not found") + } + + *droplet = retrieveDroplet + + return nil + } +} + +const testAccCheckDigitalOceanDropletConfig_basic = ` +resource "digitalocean_droplet" "foobar" { + name = "foo" + size = "512mb" + image = "centos-5-8-x32" + region = "nyc2" +} +` diff --git a/builtin/providers/digitalocean/resource_provider.go b/builtin/providers/digitalocean/resource_provider.go new file mode 100644 index 000000000..181a5264e --- /dev/null +++ b/builtin/providers/digitalocean/resource_provider.go @@ -0,0 +1,67 @@ +package digitalocean + +import ( + "log" + + "github.com/hashicorp/terraform/helper/config" + "github.com/hashicorp/terraform/terraform" + "github.com/pearkes/digitalocean" +) + +type ResourceProvider struct { + Config Config + + client *digitalocean.Client +} + +func (p *ResourceProvider) Validate(c *terraform.ResourceConfig) ([]string, []error) { + v := &config.Validator{ + Required: []string{ + "token", + }, + } + + return v.Validate(c) +} + +func (p *ResourceProvider) ValidateResource( + t string, c *terraform.ResourceConfig) ([]string, []error) { + return resourceMap.Validate(t, c) +} + +func (p *ResourceProvider) Configure(c *terraform.ResourceConfig) error { + if _, err := config.Decode(&p.Config, c.Config); err != nil { + return err + } + + log.Println("[INFO] Initializing DigitalOcean client") + var err error + p.client, err = p.Config.Client() + + if err != nil { + return err + } + + return nil +} + +func (p *ResourceProvider) Apply( + s *terraform.ResourceState, + d *terraform.ResourceDiff) (*terraform.ResourceState, error) { + return resourceMap.Apply(s, d, p) +} + +func (p *ResourceProvider) Diff( + s *terraform.ResourceState, + c *terraform.ResourceConfig) (*terraform.ResourceDiff, error) { + return resourceMap.Diff(s, c, p) +} + +func (p *ResourceProvider) Refresh( + s *terraform.ResourceState) (*terraform.ResourceState, error) { + return resourceMap.Refresh(s, p) +} + +func (p *ResourceProvider) Resources() []terraform.ResourceType { + return resourceMap.Resources() +} diff --git a/builtin/providers/digitalocean/resource_provider_test.go b/builtin/providers/digitalocean/resource_provider_test.go new file mode 100644 index 000000000..464fd6cf0 --- /dev/null +++ b/builtin/providers/digitalocean/resource_provider_test.go @@ -0,0 +1,56 @@ +package digitalocean + +import ( + "os" + "reflect" + "testing" + + "github.com/hashicorp/terraform/config" + "github.com/hashicorp/terraform/terraform" +) + +var testAccProviders map[string]terraform.ResourceProvider +var testAccProvider *ResourceProvider + +func init() { + testAccProvider = new(ResourceProvider) + testAccProviders = map[string]terraform.ResourceProvider{ + "digitalocean": testAccProvider, + } +} + +func TestResourceProvider_impl(t *testing.T) { + var _ terraform.ResourceProvider = new(ResourceProvider) +} + +func TestResourceProvider_Configure(t *testing.T) { + rp := new(ResourceProvider) + + raw := map[string]interface{}{ + "token": "foo", + } + + rawConfig, err := config.NewRawConfig(raw) + if err != nil { + t.Fatalf("err: %s", err) + } + + err = rp.Configure(terraform.NewResourceConfig(rawConfig)) + if err != nil { + t.Fatalf("err: %s", err) + } + + expected := Config{ + Token: "foo", + } + + if !reflect.DeepEqual(rp.Config, expected) { + t.Fatalf("bad: %#v", rp.Config) + } +} + +func testAccPreCheck(t *testing.T) { + if v := os.Getenv("DIGITALOCEAN_TOKEN"); v == "" { + t.Fatal("DIGITALOCEAN_TOKEN must be set for acceptance tests") + } +} diff --git a/builtin/providers/digitalocean/resources.go b/builtin/providers/digitalocean/resources.go new file mode 100644 index 000000000..75b396c52 --- /dev/null +++ b/builtin/providers/digitalocean/resources.go @@ -0,0 +1,24 @@ +package digitalocean + +import ( + "github.com/hashicorp/terraform/helper/resource" +) + +// resourceMap is the mapping of resources we support to their basic +// operations. This makes it easy to implement new resource types. +var resourceMap *resource.Map + +func init() { + resourceMap = &resource.Map{ + Mapping: map[string]resource.Resource{ + "digitalocean_droplet": resource.Resource{ + ConfigValidator: resource_digitalocean_droplet_validation(), + Create: resource_digitalocean_droplet_create, + Destroy: resource_digitalocean_droplet_destroy, + Diff: resource_digitalocean_droplet_diff, + Refresh: resource_digitalocean_droplet_refresh, + Update: resource_digitalocean_droplet_update, + }, + }, + } +} diff --git a/config.go b/config.go index efe46ad94..76023875c 100644 --- a/config.go +++ b/config.go @@ -33,7 +33,8 @@ const libuclParseFlags = libucl.ParserKeyLowercase func init() { BuiltinConfig.Providers = map[string]string{ - "aws": "terraform-provider-aws", + "aws": "terraform-provider-aws", + "digitalocean": "terraform-provider-digitalocean", } BuiltinConfig.Provisioners = map[string]string{ "local-exec": "terraform-provisioner-local-exec", diff --git a/terraform/graph.go b/terraform/graph.go index e757c3072..eb23c3ccd 100644 --- a/terraform/graph.go +++ b/terraform/graph.go @@ -46,7 +46,7 @@ type GraphOpts struct { } // GraphRootNode is the name of the root node in the Terraform resource -// graph. This node is just a placemarker and has no associated functionality. +// graph. This node ispjust a placemarker and has no associated functionality. const GraphRootNode = "root" // GraphNodeResource is a node type in the graph that represents a resource