diff --git a/builtin/providers/nomad/provider.go b/builtin/providers/nomad/provider.go new file mode 100644 index 000000000..2da83b689 --- /dev/null +++ b/builtin/providers/nomad/provider.go @@ -0,0 +1,49 @@ +package nomad + +import ( + "fmt" + + "github.com/hashicorp/nomad/api" + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/terraform" +) + +func Provider() terraform.ResourceProvider { + return &schema.Provider{ + Schema: map[string]*schema.Schema{ + "address": &schema.Schema{ + Type: schema.TypeString, + Required: true, + DefaultFunc: schema.EnvDefaultFunc("NOMAD_ADDR", nil), + Description: "URL of the root of the target Nomad agent.", + }, + + "region": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("NOMAD_REGION", ""), + Description: "Region of the target Nomad agent.", + }, + }, + + ConfigureFunc: providerConfigure, + + ResourcesMap: map[string]*schema.Resource{ + "nomad_job": resourceJob(), + }, + } +} + +func providerConfigure(d *schema.ResourceData) (interface{}, error) { + config := &api.Config{ + Address: d.Get("address").(string), + Region: d.Get("region").(string), + } + + client, err := api.NewClient(config) + if err != nil { + return nil, fmt.Errorf("failed to configure Nomad API: %s", err) + } + + return client, nil +} diff --git a/builtin/providers/nomad/provider_test.go b/builtin/providers/nomad/provider_test.go new file mode 100644 index 000000000..edbf9abe0 --- /dev/null +++ b/builtin/providers/nomad/provider_test.go @@ -0,0 +1,44 @@ +package nomad + +import ( + "os" + "testing" + + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/terraform" +) + +// How to run the acceptance tests for this provider: +// +// - Obtain an official Nomad release from https://nomadproject.io +// and extract the "nomad" binary +// +// - Run the following to start the Nomad agent in development mode: +// nomad agent -dev +// +// - Run the Terraform acceptance tests as usual: +// make testacc TEST=./builtin/providers/nomad +// +// The tests expect to be run in a fresh, empty Nomad server. + +func TestProvider(t *testing.T) { + if err := Provider().(*schema.Provider).InternalValidate(); err != nil { + t.Fatalf("err: %s", err) + } +} + +var testProvider *schema.Provider +var testProviders map[string]terraform.ResourceProvider + +func init() { + testProvider = Provider().(*schema.Provider) + testProviders = map[string]terraform.ResourceProvider{ + "nomad": testProvider, + } +} + +func testAccPreCheck(t *testing.T) { + if v := os.Getenv("NOMAD_ADDR"); v == "" { + os.Setenv("NOMAD_ADDR", "http://127.0.0.1:4646") + } +} diff --git a/builtin/providers/nomad/resource_job.go b/builtin/providers/nomad/resource_job.go new file mode 100644 index 000000000..4f0b35123 --- /dev/null +++ b/builtin/providers/nomad/resource_job.go @@ -0,0 +1,171 @@ +package nomad + +import ( + "bytes" + "encoding/gob" + "fmt" + "log" + "strings" + + "github.com/hashicorp/nomad/api" + "github.com/hashicorp/nomad/jobspec" + "github.com/hashicorp/nomad/nomad/structs" + "github.com/hashicorp/terraform/helper/schema" +) + +func resourceJob() *schema.Resource { + return &schema.Resource{ + Create: resourceJobRegister, + Update: resourceJobRegister, + Delete: resourceJobDeregister, + Read: resourceJobRead, + Exists: resourceJobExists, + + Schema: map[string]*schema.Schema{ + "jobspec": { + Description: "Job specification. If you want to point to a file use the file() function.", + Required: true, + Type: schema.TypeString, + }, + + "deregister_on_destroy": { + Description: "If true, the job will be deregistered on destroy.", + Optional: true, + Default: true, + Type: schema.TypeBool, + }, + + "deregister_on_id_change": { + Description: "If true, the job will be deregistered when the job ID changes.", + Optional: true, + Default: true, + Type: schema.TypeBool, + }, + }, + } +} + +func resourceJobRegister(d *schema.ResourceData, meta interface{}) error { + client := meta.(*api.Client) + + // Get the jobspec itself + jobspecRaw := d.Get("jobspec").(string) + + // Parse it + jobspecStruct, err := jobspec.Parse(strings.NewReader(jobspecRaw)) + if err != nil { + return fmt.Errorf("error parsing jobspec: %s", err) + } + + // Initialize and validate + jobspecStruct.Canonicalize() + if err := jobspecStruct.Validate(); err != nil { + return fmt.Errorf("Error validating job: %v", err) + } + + // If we have an ID and its not equal to this jobspec, then we + // have to deregister the old job before we register the new job. + prevId := d.Id() + if !d.Get("deregister_on_id_change").(bool) { + // If we aren't deregistering on ID change, just pretend we + // don't have a prior ID. + prevId = "" + } + if prevId != "" && prevId != jobspecStruct.ID { + log.Printf( + "[INFO] Deregistering %q before registering %q", + prevId, jobspecStruct.ID) + + log.Printf("[DEBUG] Deregistering job: %q", prevId) + _, _, err := client.Jobs().Deregister(prevId, nil) + if err != nil { + return fmt.Errorf( + "error deregistering previous job %q "+ + "before registering new job %q: %s", + prevId, jobspecStruct.ID, err) + } + + // Success! Clear our state. + d.SetId("") + } + + // Convert it so that we can use it with the API + jobspecAPI, err := convertStructJob(jobspecStruct) + if err != nil { + return fmt.Errorf("error converting jobspec: %s", err) + } + + // Register the job + _, _, err = client.Jobs().Register(jobspecAPI, nil) + if err != nil { + return fmt.Errorf("error applying jobspec: %s", err) + } + + d.SetId(jobspecAPI.ID) + + return nil +} + +func resourceJobDeregister(d *schema.ResourceData, meta interface{}) error { + client := meta.(*api.Client) + + // If deregistration is disabled, then do nothing + if !d.Get("deregister_on_destroy").(bool) { + log.Printf( + "[WARN] Job %q will not deregister since 'deregister_on_destroy'"+ + " is false", d.Id()) + return nil + } + + id := d.Id() + log.Printf("[DEBUG] Deregistering job: %q", id) + _, _, err := client.Jobs().Deregister(id, nil) + if err != nil { + return fmt.Errorf("error deregistering job: %s", err) + } + + return nil +} + +func resourceJobExists(d *schema.ResourceData, meta interface{}) (bool, error) { + client := meta.(*api.Client) + + id := d.Id() + log.Printf("[DEBUG] Checking if job exists: %q", id) + _, _, err := client.Jobs().Info(id, nil) + if err != nil { + // As of Nomad 0.4.1, the API client returns an error for 404 + // rather than a nil result, so we must check this way. + if strings.Contains(err.Error(), "404") { + return false, nil + } + + return true, fmt.Errorf("error checking for job: %#v", err) + } + + return true, nil +} + +func resourceJobRead(d *schema.ResourceData, meta interface{}) error { + // We don't do anything at the moment. Exists is used to + // remove non-existent jobs but read doesn't have to do anything. + return nil +} + +// convertStructJob is used to take a *structs.Job and convert it to an *api.Job. +// +// This is unfortunate but it is how Nomad itself does it (this is copied +// line for line from Nomad). We'll mimic them exactly to get this done. +func convertStructJob(in *structs.Job) (*api.Job, error) { + gob.Register([]map[string]interface{}{}) + gob.Register([]interface{}{}) + var apiJob *api.Job + buf := new(bytes.Buffer) + if err := gob.NewEncoder(buf).Encode(in); err != nil { + return nil, err + } + if err := gob.NewDecoder(buf).Decode(&apiJob); err != nil { + return nil, err + } + return apiJob, nil +} diff --git a/builtin/providers/nomad/resource_job_test.go b/builtin/providers/nomad/resource_job_test.go new file mode 100644 index 000000000..6562e2988 --- /dev/null +++ b/builtin/providers/nomad/resource_job_test.go @@ -0,0 +1,283 @@ +package nomad + +import ( + "fmt" + "strings" + "testing" + + r "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" + + "github.com/hashicorp/nomad/api" +) + +func TestResourceJob_basic(t *testing.T) { + r.Test(t, r.TestCase{ + Providers: testProviders, + PreCheck: func() { testAccPreCheck(t) }, + Steps: []r.TestStep{ + r.TestStep{ + Config: testResourceJob_initialConfig, + Check: testResourceJob_initialCheck, + }, + }, + + CheckDestroy: testResourceJob_checkDestroy("foo"), + }) +} + +func TestResourceJob_refresh(t *testing.T) { + r.Test(t, r.TestCase{ + Providers: testProviders, + PreCheck: func() { testAccPreCheck(t) }, + Steps: []r.TestStep{ + r.TestStep{ + Config: testResourceJob_initialConfig, + Check: testResourceJob_initialCheck, + }, + + // This should successfully cause the job to be recreated, + // testing the Exists function. + r.TestStep{ + PreConfig: testResourceJob_deregister(t, "foo"), + Config: testResourceJob_initialConfig, + }, + }, + }) +} + +func TestResourceJob_disableDestroyDeregister(t *testing.T) { + r.Test(t, r.TestCase{ + Providers: testProviders, + PreCheck: func() { testAccPreCheck(t) }, + Steps: []r.TestStep{ + r.TestStep{ + Config: testResourceJob_noDestroy, + Check: testResourceJob_initialCheck, + }, + + // Destroy with our setting set + r.TestStep{ + Destroy: true, + Config: testResourceJob_noDestroy, + Check: testResourceJob_checkExists, + }, + + // Re-apply without the setting set + r.TestStep{ + Config: testResourceJob_initialConfig, + Check: testResourceJob_checkExists, + }, + }, + }) +} + +func TestResourceJob_idChange(t *testing.T) { + r.Test(t, r.TestCase{ + Providers: testProviders, + PreCheck: func() { testAccPreCheck(t) }, + Steps: []r.TestStep{ + r.TestStep{ + Config: testResourceJob_initialConfig, + Check: testResourceJob_initialCheck, + }, + + // Change our ID + r.TestStep{ + Config: testResourceJob_updateConfig, + Check: testResourceJob_updateCheck, + }, + }, + }) +} + +var testResourceJob_initialConfig = ` +resource "nomad_job" "test" { + jobspec = <Mailgun + > + Nomad + + > Microsoft Azure diff --git a/website/source/layouts/nomad.erb b/website/source/layouts/nomad.erb new file mode 100644 index 000000000..8ea92470c --- /dev/null +++ b/website/source/layouts/nomad.erb @@ -0,0 +1,26 @@ +<% wrap_layout :inner do %> + <% content_for :sidebar do %> + + <% end %> + + <%= yield %> +<% end %>