From a42be3e6cf3d636cda58f370a75cd4e7a5008812 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Sat, 20 Jun 2015 23:10:18 -0700 Subject: [PATCH 1/6] New provider for Rundeck, a runbook automation system. --- builtin/bins/provider-rundeck/main.go | 12 +++ builtin/providers/rundeck/provider.go | 51 +++++++++++ builtin/providers/rundeck/provider_test.go | 98 ++++++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 builtin/bins/provider-rundeck/main.go create mode 100644 builtin/providers/rundeck/provider.go create mode 100644 builtin/providers/rundeck/provider_test.go diff --git a/builtin/bins/provider-rundeck/main.go b/builtin/bins/provider-rundeck/main.go new file mode 100644 index 000000000..fe95900c8 --- /dev/null +++ b/builtin/bins/provider-rundeck/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "github.com/hashicorp/terraform/builtin/providers/rundeck" + "github.com/hashicorp/terraform/plugin" +) + +func main() { + plugin.Serve(&plugin.ServeOpts{ + ProviderFunc: rundeck.Provider, + }) +} diff --git a/builtin/providers/rundeck/provider.go b/builtin/providers/rundeck/provider.go new file mode 100644 index 000000000..da5796aae --- /dev/null +++ b/builtin/providers/rundeck/provider.go @@ -0,0 +1,51 @@ +package rundeck + +import ( + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/terraform" + + "github.com/apparentlymart/go-rundeck-api/rundeck" +) + +func Provider() terraform.ResourceProvider { + return &schema.Provider{ + Schema: map[string]*schema.Schema{ + "url": &schema.Schema{ + Type: schema.TypeString, + Required: true, + DefaultFunc: schema.EnvDefaultFunc("RUNDECK_URL", nil), + Description: "URL of the root of the target Rundeck server.", + }, + "auth_token": &schema.Schema{ + Type: schema.TypeString, + Required: true, + DefaultFunc: schema.EnvDefaultFunc("RUNDECK_AUTH_TOKEN", nil), + Description: "Auth token to use with the Rundeck API.", + }, + "allow_unverified_ssl": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + Description: "If set, the Rundeck client will permit unverifiable SSL certificates.", + }, + }, + + ResourcesMap: map[string]*schema.Resource{ + //"rundeck_project": resourceRundeckProject(), + //"rundeck_job": resourceRundeckJob(), + //"rundeck_private_key": resourceRundeckPrivateKey(), + //"rundeck_public_key": resourceRundeckPublicKey(), + }, + + ConfigureFunc: providerConfigure, + } +} + +func providerConfigure(d *schema.ResourceData) (interface{}, error) { + config := &rundeck.ClientConfig{ + BaseURL: d.Get("url").(string), + AuthToken: d.Get("auth_token").(string), + AllowUnverifiedSSL: d.Get("allow_unverified_ssl").(bool), + } + + return rundeck.NewClient(config) +} diff --git a/builtin/providers/rundeck/provider_test.go b/builtin/providers/rundeck/provider_test.go new file mode 100644 index 000000000..fc3d936ba --- /dev/null +++ b/builtin/providers/rundeck/provider_test.go @@ -0,0 +1,98 @@ +package rundeck + +import ( + "os" + "testing" + + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/terraform" +) + +// To run these acceptance tests, you will need a Rundeck server. +// An easy way to get one is to use Rundeck's "Anvils" demo, which includes a Vagrantfile +// to get it running easily: +// https://github.com/rundeck/anvils-demo +// The anvils demo ships with some example security policies that don't have enough access to +// run the tests, so you need to either modify one of the stock users to have full access or +// create a new user with such access. The following block is an example that gives the +// 'admin' user and API clients open access. +// In the anvils demo the admin password is "admin" by default. + +// Place the contents of the following comment in /etc/rundeck/terraform-test.aclpolicy +/* +description: Admin, all access. +context: + project: '.*' # all projects +for: + resource: + - allow: '*' # allow read/create all kinds + adhoc: + - allow: '*' # allow read/running/killing adhoc jobs + job: + - allow: '*' # allow read/write/delete/run/kill of all jobs + node: + - allow: '*' # allow read/run for all nodes +by: + group: admin +--- +description: Admin, all access. +context: + application: 'rundeck' +for: + resource: + - allow: '*' # allow create of projects + project: + - allow: '*' # allow view/admin of all projects + storage: + - allow: '*' # allow read/create/update/delete for all /keys/* storage content +by: + group: admin +--- +description: Admin API, all access. +context: + application: 'rundeck' +for: + resource: + - allow: '*' # allow create of projects + project: + - allow: '*' # allow view/admin of all projects + storage: + - allow: '*' # allow read/create/update/delete for all /keys/* storage content +by: + group: api_token_group +*/ + +// Once you've got a user set up, put that user's API auth token in the RUNDECK_AUTH_TOKEN +// environment variable, and put the URL of the Rundeck home page in the RUNDECK_URL variable. +// If you're using the Anvils demo in its default configuration, you can find or generate an API +// token at http://192.168.50.2:4440/user/profile once you've logged in, and RUNDECK_URL will +// be http://192.168.50.2:4440/ . + +var testAccProviders map[string]terraform.ResourceProvider +var testAccProvider *schema.Provider + +func init() { + testAccProvider = Provider().(*schema.Provider) + testAccProviders = map[string]terraform.ResourceProvider{ + "rundeck": 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) { + if v := os.Getenv("RUNDECK_URL"); v == "" { + t.Fatal("RUNDECK_URL must be set for acceptance tests") + } + if v := os.Getenv("RUNDECK_AUTH_TOKEN"); v == "" { + t.Fatal("RUNDECK_AUTH_TOKEN must be set for acceptance tests") + } +} From f0947661fb4bffc5de74e73ee12f49a69dc97b19 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Sun, 21 Jun 2015 10:26:11 -0700 Subject: [PATCH 2/6] rundeck_project resource type. --- builtin/providers/rundeck/provider.go | 2 +- builtin/providers/rundeck/resource_project.go | 293 ++++++++++++++++++ .../rundeck/resource_project_test.go | 98 ++++++ 3 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 builtin/providers/rundeck/resource_project.go create mode 100644 builtin/providers/rundeck/resource_project_test.go diff --git a/builtin/providers/rundeck/provider.go b/builtin/providers/rundeck/provider.go index da5796aae..5e547e15e 100644 --- a/builtin/providers/rundeck/provider.go +++ b/builtin/providers/rundeck/provider.go @@ -30,7 +30,7 @@ func Provider() terraform.ResourceProvider { }, ResourcesMap: map[string]*schema.Resource{ - //"rundeck_project": resourceRundeckProject(), + "rundeck_project": resourceRundeckProject(), //"rundeck_job": resourceRundeckJob(), //"rundeck_private_key": resourceRundeckPrivateKey(), //"rundeck_public_key": resourceRundeckPublicKey(), diff --git a/builtin/providers/rundeck/resource_project.go b/builtin/providers/rundeck/resource_project.go new file mode 100644 index 000000000..d355555b0 --- /dev/null +++ b/builtin/providers/rundeck/resource_project.go @@ -0,0 +1,293 @@ +package rundeck + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/hashicorp/terraform/helper/schema" + + "github.com/apparentlymart/go-rundeck-api/rundeck" +) + +var projectConfigAttributes = map[string]string{ + "project.name": "name", + "project.description": "description", + "service.FileCopier.default.provider": "default_node_file_copier_plugin", + "service.NodeExecutor.default.provider": "default_node_executor_plugin", + "project.ssh-authentication": "ssh_authentication_type", + "project.ssh-key-storage-path": "ssh_key_storage_path", + "project.ssh-keypath": "ssh_key_file_path", +} + +func resourceRundeckProject() *schema.Resource { + return &schema.Resource{ + Create: CreateProject, + Update: UpdateProject, + Delete: DeleteProject, + Exists: ProjectExists, + Read: ReadProject, + + Schema: map[string]*schema.Schema{ + "name": &schema.Schema{ + Type: schema.TypeString, + Required: true, + Description: "Unique name for the project", + ForceNew: true, + }, + + "description": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Description: "Description of the project to be shown in the Rundeck UI", + Default: "Managed by Terraform", + }, + + "ui_url": &schema.Schema{ + Type: schema.TypeString, + Required: false, + Computed: true, + }, + + "resource_model_source": &schema.Schema{ + Type: schema.TypeList, + Required: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "type": &schema.Schema{ + Type: schema.TypeString, + Required: true, + Description: "Name of the resource model plugin to use", + }, + "config": &schema.Schema{ + Type: schema.TypeMap, + Required: true, + Description: "Configuration parameters for the selected plugin", + }, + }, + }, + }, + + "default_node_file_copier_plugin": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Default: "jsch-scp", + }, + + "default_node_executor_plugin": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Default: "jsch-ssh", + }, + + "ssh_authentication_type": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Default: "privateKey", + }, + + "ssh_key_storage_path": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + + "ssh_key_file_path": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + + "extra_config": &schema.Schema{ + Type: schema.TypeMap, + Optional: true, + Description: "Additional raw configuration parameters to include in the project configuration, with dots replaced with slashes in the key names due to limitations in Terraform's config language.", + }, + }, + } +} + +func CreateProject(d *schema.ResourceData, meta interface{}) error { + client := meta.(*rundeck.Client) + + // Rundeck's model is a little inconsistent in that we can create + // a project via a high-level structure but yet we must update + // the project via its raw config properties. + // For simplicity's sake we create a bare minimum project here + // and then delegate to UpdateProject to fill in the rest of the + // configuration via the raw config properties. + + project, err := client.CreateProject(&rundeck.Project{ + Name: d.Get("name").(string), + }) + + if err != nil { + return err + } + + d.SetId(project.Name) + d.Set("id", project.Name) + + return UpdateProject(d, meta) +} + +func UpdateProject(d *schema.ResourceData, meta interface{}) error { + client := meta.(*rundeck.Client) + + // In Rundeck, updates are always in terms of the low-level config + // properties map, so we need to transform our data structure + // into the equivalent raw properties. + + projectName := d.Id() + + updateMap := map[string]string{} + + slashReplacer := strings.NewReplacer("/", ".") + if extraConfig := d.Get("extra_config"); extraConfig != nil { + for k, v := range extraConfig.(map[string]interface{}) { + updateMap[slashReplacer.Replace(k)] = v.(string) + } + } + + for configKey, attrKey := range projectConfigAttributes { + v := d.Get(attrKey).(string) + if v != "" { + updateMap[configKey] = v + } + } + + for i, rmsi := range d.Get("resource_model_source").([]interface{}) { + rms := rmsi.(map[string]interface{}) + pluginType := rms["type"].(string) + ci := rms["config"].(map[string]interface{}) + attrKeyPrefix := fmt.Sprintf("resources.source.%v.", i+1) + typeKey := attrKeyPrefix + "type" + configKeyPrefix := fmt.Sprintf("%vconfig.", attrKeyPrefix) + updateMap[typeKey] = pluginType + for k, v := range ci { + updateMap[configKeyPrefix+k] = v.(string) + } + } + + err := client.SetProjectConfig(projectName, updateMap) + + if err != nil { + return err + } + + return ReadProject(d, meta) +} + +func ReadProject(d *schema.ResourceData, meta interface{}) error { + client := meta.(*rundeck.Client) + + name := d.Id() + project, err := client.GetProject(name) + + if err != nil { + return err + } + + for configKey, attrKey := range projectConfigAttributes { + d.Set(projectConfigAttributes[configKey], nil) + if v, ok := project.Config[configKey]; ok { + d.Set(attrKey, v) + // Remove this key so it won't get included in extra_config + // later. + delete(project.Config, configKey) + } + } + + resourceSourceMap := map[int]interface{}{} + configMaps := map[int]interface{}{} + for configKey, v := range project.Config { + if strings.HasPrefix(configKey, "resources.source.") { + nameParts := strings.Split(configKey, ".") + + if len(nameParts) < 4 { + continue + } + + index, err := strconv.Atoi(nameParts[2]) + if err != nil { + continue + } + + if _, ok := resourceSourceMap[index]; !ok { + configMap := map[string]interface{}{} + configMaps[index] = configMap + resourceSourceMap[index] = map[string]interface{}{ + "config": configMap, + } + } + + switch nameParts[3] { + case "type": + if len(nameParts) != 4 { + continue + } + m := resourceSourceMap[index].(map[string]interface{}) + m["type"] = v + case "config": + if len(nameParts) != 5 { + continue + } + m := configMaps[index].(map[string]interface{}) + m[nameParts[4]] = v + default: + continue + } + + // Remove this key so it won't get included in extra_config + // later. + delete(project.Config, configKey) + } + } + + resourceSources := []map[string]interface{}{} + resourceSourceIndices := []int{} + for k := range resourceSourceMap { + resourceSourceIndices = append(resourceSourceIndices, k) + } + sort.Ints(resourceSourceIndices) + + for _, index := range resourceSourceIndices { + resourceSources = append(resourceSources, resourceSourceMap[index].(map[string]interface{})) + } + d.Set("resource_model_source", resourceSources) + + extraConfig := map[string]string{} + dotReplacer := strings.NewReplacer(".", "/") + for k, v := range project.Config { + extraConfig[dotReplacer.Replace(k)] = v + } + d.Set("extra_config", extraConfig) + + d.Set("name", project.Name) + d.Set("ui_url", project.URL) + + return nil +} + +func ProjectExists(d *schema.ResourceData, meta interface{}) (bool, error) { + client := meta.(*rundeck.Client) + + name := d.Id() + _, err := client.GetProject(name) + + if _, ok := err.(rundeck.NotFoundError); ok { + return false, nil + } + + if err != nil { + return false, err + } + + return true, nil +} + +func DeleteProject(d *schema.ResourceData, meta interface{}) error { + client := meta.(*rundeck.Client) + + name := d.Id() + return client.DeleteProject(name) +} diff --git a/builtin/providers/rundeck/resource_project_test.go b/builtin/providers/rundeck/resource_project_test.go new file mode 100644 index 000000000..1627a7824 --- /dev/null +++ b/builtin/providers/rundeck/resource_project_test.go @@ -0,0 +1,98 @@ +package rundeck + +import ( + "fmt" + "testing" + + "github.com/apparentlymart/go-rundeck-api/rundeck" + + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +func TestAccProject_basic(t *testing.T) { + var project rundeck.Project + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccProjectCheckDestroy(&project), + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccProjectConfig_basic, + Check: resource.ComposeTestCheckFunc( + testAccProjectCheckExists("rundeck_project.main", &project), + func(s *terraform.State) error { + if expected := "terraform-acc-test-basic"; project.Name != expected { + return fmt.Errorf("wrong name; expected %v, got %v", expected, project.Name) + } + if expected := "baz"; project.Config["foo.bar"] != expected { + return fmt.Errorf("wrong foo.bar config; expected %v, got %v", expected, project.Config["foo.bar"]) + } + if expected := "file"; project.Config["resources.source.1.type"] != expected { + return fmt.Errorf("wrong resources.source.1.type config; expected %v, got %v", expected, project.Config["resources.source.1.type"]) + } + return nil + }, + ), + }, + }, + }) +} + +func testAccProjectCheckDestroy(project *rundeck.Project) resource.TestCheckFunc { + return func(s *terraform.State) error { + client := testAccProvider.Meta().(*rundeck.Client) + _, err := client.GetProject(project.Name) + if err == nil { + return fmt.Errorf("project still exists") + } + if _, ok := err.(*rundeck.NotFoundError); !ok { + return fmt.Errorf("got something other than NotFoundError (%v) when getting project", err) + } + + return nil + } +} + +func testAccProjectCheckExists(rn string, project *rundeck.Project) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[rn] + if !ok { + return fmt.Errorf("resource not found: %s", rn) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("project id not set") + } + + client := testAccProvider.Meta().(*rundeck.Client) + gotProject, err := client.GetProject(rs.Primary.ID) + if err != nil { + return fmt.Errorf("error getting project: %s", err) + } + + *project = *gotProject + + return nil + } +} + +const testAccProjectConfig_basic = ` +resource "rundeck_project" "main" { + name = "terraform-acc-test-basic" + description = "Terraform Acceptance Tests Basic Project" + + resource_model_source { + type = "file" + config = { + format = "resourcexml" + file = "/tmp/terraform-acc-tests.xml" + } + } + + extra_config = { + "foo/bar" = "baz" + } +} +` From aba9698cf8456eaffd988dc81c1441928392dc43 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Mon, 22 Jun 2015 09:23:42 -0700 Subject: [PATCH 3/6] rundeck_public_key resource type. --- builtin/providers/rundeck/provider.go | 2 +- .../providers/rundeck/resource_public_key.go | 148 ++++++++++++++++++ .../rundeck/resource_public_key_test.go | 99 ++++++++++++ 3 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 builtin/providers/rundeck/resource_public_key.go create mode 100644 builtin/providers/rundeck/resource_public_key_test.go diff --git a/builtin/providers/rundeck/provider.go b/builtin/providers/rundeck/provider.go index 5e547e15e..4a59d8cfe 100644 --- a/builtin/providers/rundeck/provider.go +++ b/builtin/providers/rundeck/provider.go @@ -33,7 +33,7 @@ func Provider() terraform.ResourceProvider { "rundeck_project": resourceRundeckProject(), //"rundeck_job": resourceRundeckJob(), //"rundeck_private_key": resourceRundeckPrivateKey(), - //"rundeck_public_key": resourceRundeckPublicKey(), + "rundeck_public_key": resourceRundeckPublicKey(), }, ConfigureFunc: providerConfigure, diff --git a/builtin/providers/rundeck/resource_public_key.go b/builtin/providers/rundeck/resource_public_key.go new file mode 100644 index 000000000..11ee2a3f9 --- /dev/null +++ b/builtin/providers/rundeck/resource_public_key.go @@ -0,0 +1,148 @@ +package rundeck + +import ( + "github.com/hashicorp/terraform/helper/schema" + + "github.com/apparentlymart/go-rundeck-api/rundeck" +) + +func resourceRundeckPublicKey() *schema.Resource { + return &schema.Resource{ + Create: CreatePublicKey, + Update: UpdatePublicKey, + Delete: DeletePublicKey, + Exists: PublicKeyExists, + Read: ReadPublicKey, + + Schema: map[string]*schema.Schema{ + "path": &schema.Schema{ + Type: schema.TypeString, + Required: true, + Description: "Path to the key within the key store", + ForceNew: true, + }, + + "key_material": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "The public key data to store, in the usual OpenSSH public key file format", + }, + + "url": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + Description: "URL at which the key content can be retrieved", + }, + + "delete": &schema.Schema{ + Type: schema.TypeBool, + Computed: true, + Description: "True if the key should be deleted when the resource is deleted. Defaults to true if key_material is provided in the configuration.", + }, + }, + } +} + +func CreatePublicKey(d *schema.ResourceData, meta interface{}) error { + client := meta.(*rundeck.Client) + + path := d.Get("path").(string) + keyMaterial := d.Get("key_material").(string) + + if keyMaterial != "" { + err := client.CreatePublicKey(path, keyMaterial) + if err != nil { + return err + } + d.Set("delete", true) + } + + d.SetId(path) + + return ReadPublicKey(d, meta) +} + +func UpdatePublicKey(d *schema.ResourceData, meta interface{}) error { + client := meta.(*rundeck.Client) + + if d.HasChange("key_material") { + path := d.Get("path").(string) + keyMaterial := d.Get("key_material").(string) + + err := client.ReplacePublicKey(path, keyMaterial) + if err != nil { + return err + } + } + + return ReadPublicKey(d, meta) +} + +func DeletePublicKey(d *schema.ResourceData, meta interface{}) error { + client := meta.(*rundeck.Client) + + path := d.Id() + + // Since this resource can be used both to create and to read existing + // public keys, we'll only actually delete the key if we remember that + // we created the key in the first place, or if the user explicitly + // opted in to have an existing key deleted. + if d.Get("delete").(bool) { + // The only "delete" call we have is oblivious to key type, but + // that's okay since our Exists implementation makes sure that we + // won't try to delete a key of the wrong type since we'll pretend + // that it's already been deleted. + err := client.DeleteKey(path) + if err != nil { + return err + } + } + + d.SetId("") + return nil +} + +func ReadPublicKey(d *schema.ResourceData, meta interface{}) error { + client := meta.(*rundeck.Client) + + path := d.Id() + + key, err := client.GetKeyMeta(path) + if err != nil { + return err + } + + keyMaterial, err := client.GetKeyContent(path) + if err != nil { + return err + } + + d.Set("key_material", keyMaterial) + d.Set("url", key.URL) + + return nil +} + +func PublicKeyExists(d *schema.ResourceData, meta interface{}) (bool, error) { + client := meta.(*rundeck.Client) + + path := d.Id() + + key, err := client.GetKeyMeta(path) + if err != nil { + if _, ok := err.(rundeck.NotFoundError); ok { + err = nil + } + return false, err + } + + if key.KeyType != "public" { + // If the key type isn't public then as far as this resource is + // concerned it doesn't exist. (We'll fail properly when we try to + // create a key where one already exists.) + return false, nil + } + + return true, nil +} diff --git a/builtin/providers/rundeck/resource_public_key_test.go b/builtin/providers/rundeck/resource_public_key_test.go new file mode 100644 index 000000000..c8b9a1865 --- /dev/null +++ b/builtin/providers/rundeck/resource_public_key_test.go @@ -0,0 +1,99 @@ +package rundeck + +import ( + "fmt" + "strings" + "testing" + + "github.com/apparentlymart/go-rundeck-api/rundeck" + + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +func TestAccPublicKey_basic(t *testing.T) { + var key rundeck.KeyMeta + var keyMaterial string + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccPublicKeyCheckDestroy(&key), + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccPublicKeyConfig_basic, + Check: resource.ComposeTestCheckFunc( + testAccPublicKeyCheckExists("rundeck_public_key.test", &key, &keyMaterial), + func(s *terraform.State) error { + if expected := "keys/terraform_acceptance_tests/public_key"; key.Path != expected { + return fmt.Errorf("wrong path; expected %v, got %v", expected, key.Path) + } + if !strings.HasSuffix(key.URL, "/storage/keys/terraform_acceptance_tests/public_key") { + return fmt.Errorf("wrong URL; expected to end with the key path") + } + if expected := "file"; key.ResourceType != expected { + return fmt.Errorf("wrong resource type; expected %v, got %v", expected, key.ResourceType) + } + if expected := "public"; key.KeyType != expected { + return fmt.Errorf("wrong key type; expected %v, got %v", expected, key.KeyType) + } + if !strings.Contains(keyMaterial, "test+public+key+for+terraform") { + return fmt.Errorf("wrong key material") + } + return nil + }, + ), + }, + }, + }) +} + +func testAccPublicKeyCheckDestroy(key *rundeck.KeyMeta) resource.TestCheckFunc { + return func(s *terraform.State) error { + client := testAccProvider.Meta().(*rundeck.Client) + _, err := client.GetKeyMeta(key.Path) + if err == nil { + return fmt.Errorf("key still exists") + } + if _, ok := err.(*rundeck.NotFoundError); !ok { + return fmt.Errorf("got something other than NotFoundError (%v) when getting key", err) + } + + return nil + } +} + +func testAccPublicKeyCheckExists(rn string, key *rundeck.KeyMeta, keyMaterial *string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[rn] + if !ok { + return fmt.Errorf("resource not found: %s", rn) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("key id not set") + } + + client := testAccProvider.Meta().(*rundeck.Client) + gotKey, err := client.GetKeyMeta(rs.Primary.ID) + if err != nil { + return fmt.Errorf("error getting key metadata: %s", err) + } + + *key = *gotKey + + *keyMaterial, err = client.GetKeyContent(rs.Primary.ID) + if err != nil { + return fmt.Errorf("error getting key contents: %s", err) + } + + return nil + } +} + +const testAccPublicKeyConfig_basic = ` +resource "rundeck_public_key" "test" { + path = "terraform_acceptance_tests/public_key" + key_material = "ssh-rsa test+public+key+for+terraform nobody@nowhere" +} +` From bb42821f74e80e880b0627d67b5ebd044ab2acb9 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Mon, 22 Jun 2015 10:01:00 -0700 Subject: [PATCH 4/6] rundeck_private_key resource type. --- builtin/providers/rundeck/provider.go | 2 +- .../providers/rundeck/resource_private_key.go | 114 ++++++++++++++++++ .../rundeck/resource_private_key_test.go | 92 ++++++++++++++ 3 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 builtin/providers/rundeck/resource_private_key.go create mode 100644 builtin/providers/rundeck/resource_private_key_test.go diff --git a/builtin/providers/rundeck/provider.go b/builtin/providers/rundeck/provider.go index 4a59d8cfe..71506ef1f 100644 --- a/builtin/providers/rundeck/provider.go +++ b/builtin/providers/rundeck/provider.go @@ -32,7 +32,7 @@ func Provider() terraform.ResourceProvider { ResourcesMap: map[string]*schema.Resource{ "rundeck_project": resourceRundeckProject(), //"rundeck_job": resourceRundeckJob(), - //"rundeck_private_key": resourceRundeckPrivateKey(), + "rundeck_private_key": resourceRundeckPrivateKey(), "rundeck_public_key": resourceRundeckPublicKey(), }, diff --git a/builtin/providers/rundeck/resource_private_key.go b/builtin/providers/rundeck/resource_private_key.go new file mode 100644 index 000000000..a717f85f1 --- /dev/null +++ b/builtin/providers/rundeck/resource_private_key.go @@ -0,0 +1,114 @@ +package rundeck + +import ( + "crypto/sha1" + "encoding/hex" + + "github.com/hashicorp/terraform/helper/schema" + + "github.com/apparentlymart/go-rundeck-api/rundeck" +) + +func resourceRundeckPrivateKey() *schema.Resource { + return &schema.Resource{ + Create: CreateOrUpdatePrivateKey, + Update: CreateOrUpdatePrivateKey, + Delete: DeletePrivateKey, + Exists: PrivateKeyExists, + Read: ReadPrivateKey, + + Schema: map[string]*schema.Schema{ + "path": &schema.Schema{ + Type: schema.TypeString, + Required: true, + Description: "Path to the key within the key store", + ForceNew: true, + }, + + "key_material": &schema.Schema{ + Type: schema.TypeString, + Required: true, + Description: "The private key material to store, in PEM format", + StateFunc: func(v interface{}) string { + switch v.(type) { + case string: + hash := sha1.Sum([]byte(v.(string))) + return hex.EncodeToString(hash[:]) + default: + return "" + } + }, + }, + }, + } +} + +func CreateOrUpdatePrivateKey(d *schema.ResourceData, meta interface{}) error { + client := meta.(*rundeck.Client) + + path := d.Get("path").(string) + keyMaterial := d.Get("key_material").(string) + + var err error + + if d.Id() != "" { + err = client.ReplacePrivateKey(path, keyMaterial) + } else { + err = client.CreatePrivateKey(path, keyMaterial) + } + + if err != nil { + return err + } + + d.SetId(path) + + return ReadPrivateKey(d, meta) +} + +func DeletePrivateKey(d *schema.ResourceData, meta interface{}) error { + client := meta.(*rundeck.Client) + + path := d.Id() + + // The only "delete" call we have is oblivious to key type, but + // that's okay since our Exists implementation makes sure that we + // won't try to delete a key of the wrong type since we'll pretend + // that it's already been deleted. + err := client.DeleteKey(path) + if err != nil { + return err + } + + d.SetId("") + return nil +} + +func ReadPrivateKey(d *schema.ResourceData, meta interface{}) error { + // Nothing to read for a private key: existence is all we need to + // worry about, and PrivateKeyExists took care of that. + return nil +} + +func PrivateKeyExists(d *schema.ResourceData, meta interface{}) (bool, error) { + client := meta.(*rundeck.Client) + + path := d.Id() + + key, err := client.GetKeyMeta(path) + if err != nil { + if _, ok := err.(rundeck.NotFoundError); ok { + err = nil + } + return false, err + } + + if key.KeyType != "private" { + // If the key type isn't public then as far as this resource is + // concerned it doesn't exist. (We'll fail properly when we try to + // create a key where one already exists.) + return false, nil + } + + return true, nil +} diff --git a/builtin/providers/rundeck/resource_private_key_test.go b/builtin/providers/rundeck/resource_private_key_test.go new file mode 100644 index 000000000..da2dad67f --- /dev/null +++ b/builtin/providers/rundeck/resource_private_key_test.go @@ -0,0 +1,92 @@ +package rundeck + +import ( + "fmt" + "strings" + "testing" + + "github.com/apparentlymart/go-rundeck-api/rundeck" + + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +func TestAccPrivateKey_basic(t *testing.T) { + var key rundeck.KeyMeta + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccPrivateKeyCheckDestroy(&key), + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccPrivateKeyConfig_basic, + Check: resource.ComposeTestCheckFunc( + testAccPrivateKeyCheckExists("rundeck_private_key.test", &key), + func(s *terraform.State) error { + if expected := "keys/terraform_acceptance_tests/private_key"; key.Path != expected { + return fmt.Errorf("wrong path; expected %v, got %v", expected, key.Path) + } + if !strings.HasSuffix(key.URL, "/storage/keys/terraform_acceptance_tests/private_key") { + return fmt.Errorf("wrong URL; expected to end with the key path") + } + if expected := "file"; key.ResourceType != expected { + return fmt.Errorf("wrong resource type; expected %v, got %v", expected, key.ResourceType) + } + if expected := "private"; key.KeyType != expected { + return fmt.Errorf("wrong key type; expected %v, got %v", expected, key.KeyType) + } + // Rundeck won't let us re-retrieve a private key payload, so we can't test + // that the key material was submitted and stored correctly. + return nil + }, + ), + }, + }, + }) +} + +func testAccPrivateKeyCheckDestroy(key *rundeck.KeyMeta) resource.TestCheckFunc { + return func(s *terraform.State) error { + client := testAccProvider.Meta().(*rundeck.Client) + _, err := client.GetKeyMeta(key.Path) + if err == nil { + return fmt.Errorf("key still exists") + } + if _, ok := err.(*rundeck.NotFoundError); !ok { + return fmt.Errorf("got something other than NotFoundError (%v) when getting key", err) + } + + return nil + } +} + +func testAccPrivateKeyCheckExists(rn string, key *rundeck.KeyMeta) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[rn] + if !ok { + return fmt.Errorf("resource not found: %s", rn) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("key id not set") + } + + client := testAccProvider.Meta().(*rundeck.Client) + gotKey, err := client.GetKeyMeta(rs.Primary.ID) + if err != nil { + return fmt.Errorf("error getting key metadata: %s", err) + } + + *key = *gotKey + + return nil + } +} + +const testAccPrivateKeyConfig_basic = ` +resource "rundeck_private_key" "test" { + path = "terraform_acceptance_tests/private_key" + key_material = "this is not a real private key" +} +` From 40aafe4455d33d2403faf9d576014fb02969922f Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Thu, 25 Jun 2015 18:00:23 -0700 Subject: [PATCH 5/6] rundeck_job resource type. --- builtin/providers/rundeck/provider.go | 4 +- builtin/providers/rundeck/resource_job.go | 558 ++++++++++++++++++ .../providers/rundeck/resource_job_test.go | 103 ++++ 3 files changed, 663 insertions(+), 2 deletions(-) create mode 100644 builtin/providers/rundeck/resource_job.go create mode 100644 builtin/providers/rundeck/resource_job_test.go diff --git a/builtin/providers/rundeck/provider.go b/builtin/providers/rundeck/provider.go index 71506ef1f..8c4701a04 100644 --- a/builtin/providers/rundeck/provider.go +++ b/builtin/providers/rundeck/provider.go @@ -30,8 +30,8 @@ func Provider() terraform.ResourceProvider { }, ResourcesMap: map[string]*schema.Resource{ - "rundeck_project": resourceRundeckProject(), - //"rundeck_job": resourceRundeckJob(), + "rundeck_project": resourceRundeckProject(), + "rundeck_job": resourceRundeckJob(), "rundeck_private_key": resourceRundeckPrivateKey(), "rundeck_public_key": resourceRundeckPublicKey(), }, diff --git a/builtin/providers/rundeck/resource_job.go b/builtin/providers/rundeck/resource_job.go new file mode 100644 index 000000000..253414dba --- /dev/null +++ b/builtin/providers/rundeck/resource_job.go @@ -0,0 +1,558 @@ +package rundeck + +import ( + "fmt" + + "github.com/hashicorp/terraform/helper/schema" + + "github.com/apparentlymart/go-rundeck-api/rundeck" +) + +func resourceRundeckJob() *schema.Resource { + return &schema.Resource{ + Create: CreateJob, + Update: UpdateJob, + Delete: DeleteJob, + Exists: JobExists, + Read: ReadJob, + + Schema: map[string]*schema.Schema{ + "id": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + }, + + "name": &schema.Schema{ + Type: schema.TypeString, + Required: true, + }, + + "group_name": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + + "project_name": &schema.Schema{ + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + + "description": &schema.Schema{ + Type: schema.TypeString, + Required: true, + }, + + "log_level": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Default: "INFO", + }, + + "allow_concurrent_executions": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + }, + + "max_thread_count": &schema.Schema{ + Type: schema.TypeInt, + Optional: true, + Default: 1, + }, + + "continue_on_error": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + }, + + "rank_order": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Default: "ascending", + }, + + "rank_attribute": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + + "preserve_options_order": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + }, + + "command_ordering_strategy": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Default: "node-first", + }, + + "node_filter_query": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + + "node_filter_exclude_precedence": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + }, + + "option": &schema.Schema{ + // This is a list because order is important when preserve_options_order is + // set. When it's not set the order is unimportant but preserved by Rundeck/ + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": &schema.Schema{ + Type: schema.TypeString, + Required: true, + }, + + "default_value": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + + "value_choices": &schema.Schema{ + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + + "value_choices_url": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + + "require_predefined_choice": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + }, + + "validation_regex": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + + "description": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + + "required": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + }, + + "allow_multiple_values": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + }, + + "multi_value_delimiter": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + + "obscure_input": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + }, + + "exposed_to_scripts": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + }, + }, + }, + }, + + "command": &schema.Schema{ + Type: schema.TypeList, + Required: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "shell_command": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + + "inline_script": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + + "script_file": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + + "script_file_args": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + + "job": &schema.Schema{ + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": &schema.Schema{ + Type: schema.TypeString, + Required: true, + }, + "group_name": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "run_for_each_node": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + }, + "args": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + }, + }, + }, + + "step_plugin": &schema.Schema{ + Type: schema.TypeList, + Optional: true, + Elem: resourceRundeckJobPluginResource(), + }, + + "node_step_plugin": &schema.Schema{ + Type: schema.TypeList, + Optional: true, + Elem: resourceRundeckJobPluginResource(), + }, + }, + }, + }, + }, + } +} + +func resourceRundeckJobPluginResource() *schema.Resource { + return &schema.Resource{ + Schema: map[string]*schema.Schema{ + "type": &schema.Schema{ + Type: schema.TypeString, + Required: true, + }, + "config": &schema.Schema{ + Type: schema.TypeMap, + Optional: true, + }, + }, + } +} + +func CreateJob(d *schema.ResourceData, meta interface{}) error { + client := meta.(*rundeck.Client) + + job, err := jobFromResourceData(d) + if err != nil { + return err + } + + jobSummary, err := client.CreateJob(job) + if err != nil { + return err + } + + d.SetId(jobSummary.ID) + d.Set("id", jobSummary.ID) + + return ReadJob(d, meta) +} + +func UpdateJob(d *schema.ResourceData, meta interface{}) error { + client := meta.(*rundeck.Client) + + job, err := jobFromResourceData(d) + if err != nil { + return err + } + + jobSummary, err := client.CreateOrUpdateJob(job) + if err != nil { + return err + } + + d.SetId(jobSummary.ID) + d.Set("id", jobSummary.ID) + + return ReadJob(d, meta) +} + +func DeleteJob(d *schema.ResourceData, meta interface{}) error { + client := meta.(*rundeck.Client) + + err := client.DeleteJob(d.Id()) + if err != nil { + return err + } + + d.SetId("") + + return nil +} + +func JobExists(d *schema.ResourceData, meta interface{}) (bool, error) { + client := meta.(*rundeck.Client) + + _, err := client.GetJob(d.Id()) + if err != nil { + if _, ok := err.(rundeck.NotFoundError); ok { + err = nil + } + return false, err + } + + return true, nil +} + +func ReadJob(d *schema.ResourceData, meta interface{}) error { + client := meta.(*rundeck.Client) + + job, err := client.GetJob(d.Id()) + if err != nil { + return err + } + + return jobToResourceData(job, d) +} + +func jobFromResourceData(d *schema.ResourceData) (*rundeck.JobDetail, error) { + job := &rundeck.JobDetail{ + ID: d.Id(), + Name: d.Get("name").(string), + GroupName: d.Get("group_name").(string), + ProjectName: d.Get("project_name").(string), + Description: d.Get("description").(string), + LogLevel: d.Get("log_level").(string), + AllowConcurrentExecutions: d.Get("allow_concurrent_executions").(bool), + Dispatch: &rundeck.JobDispatch{ + MaxThreadCount: d.Get("max_thread_count").(int), + ContinueOnError: d.Get("continue_on_error").(bool), + RankAttribute: d.Get("rank_attribute").(string), + RankOrder: d.Get("rank_order").(string), + }, + } + + sequence := &rundeck.JobCommandSequence{ + ContinueOnError: d.Get("continue_on_error").(bool), + OrderingStrategy: d.Get("command_ordering_strategy").(string), + Commands: []rundeck.JobCommand{}, + } + + commandConfigs := d.Get("command").([]interface{}) + for _, commandI := range commandConfigs { + commandMap := commandI.(map[string]interface{}) + command := rundeck.JobCommand{ + ShellCommand: commandMap["shell_command"].(string), + Script: commandMap["inline_script"].(string), + ScriptFile: commandMap["script_file"].(string), + ScriptFileArgs: commandMap["script_file_args"].(string), + } + + jobRefsI := commandMap["job"].([]interface{}) + if len(jobRefsI) > 1 { + return nil, fmt.Errorf("rundeck command may have no more than one job") + } + if len(jobRefsI) > 0 { + jobRefMap := jobRefsI[0].(map[string]interface{}) + command.Job = &rundeck.JobCommandJobRef{ + Name: jobRefMap["name"].(string), + GroupName: jobRefMap["group_name"].(string), + RunForEachNode: jobRefMap["run_for_each_node"].(bool), + Arguments: rundeck.JobCommandJobRefArguments(jobRefMap["args"].(string)), + } + } + + stepPluginsI := commandMap["step_plugin"].([]interface{}) + if len(stepPluginsI) > 1 { + return nil, fmt.Errorf("rundeck command may have no more than one step plugin") + } + if len(stepPluginsI) > 0 { + stepPluginMap := stepPluginsI[0].(map[string]interface{}) + configI := stepPluginMap["config"].(map[string]interface{}) + config := map[string]string{} + for k, v := range configI { + config[k] = v.(string) + } + command.StepPlugin = &rundeck.JobPlugin{ + Type: stepPluginMap["type"].(string), + Config: config, + } + } + + stepPluginsI = commandMap["node_step_plugin"].([]interface{}) + if len(stepPluginsI) > 1 { + return nil, fmt.Errorf("rundeck command may have no more than one node step plugin") + } + if len(stepPluginsI) > 0 { + stepPluginMap := stepPluginsI[0].(map[string]interface{}) + configI := stepPluginMap["config"].(map[string]interface{}) + config := map[string]string{} + for k, v := range configI { + config[k] = v.(string) + } + command.NodeStepPlugin = &rundeck.JobPlugin{ + Type: stepPluginMap["type"].(string), + Config: config, + } + } + + sequence.Commands = append(sequence.Commands, command) + } + job.CommandSequence = sequence + + optionConfigsI := d.Get("option").([]interface{}) + if len(optionConfigsI) > 0 { + optionsConfig := &rundeck.JobOptions{ + PreserveOrder: d.Get("preserve_options_order").(bool), + Options: []rundeck.JobOption{}, + } + for _, optionI := range optionConfigsI { + optionMap := optionI.(map[string]interface{}) + option := rundeck.JobOption{ + Name: optionMap["name"].(string), + DefaultValue: optionMap["default_value"].(string), + ValueChoices: rundeck.JobValueChoices([]string{}), + ValueChoicesURL: optionMap["value_choices_url"].(string), + RequirePredefinedChoice: optionMap["require_predefined_choice"].(bool), + ValidationRegex: optionMap["validation_regex"].(string), + Description: optionMap["description"].(string), + IsRequired: optionMap["required"].(bool), + AllowsMultipleValues: optionMap["allow_multiple_values"].(bool), + MultiValueDelimiter: optionMap["multi_value_delimiter"].(string), + ObscureInput: optionMap["obscure_input"].(bool), + ValueIsExposedToScripts: optionMap["exposed_to_scripts"].(bool), + } + + for _, iv := range optionMap["value_choices"].([]interface{}) { + option.ValueChoices = append(option.ValueChoices, iv.(string)) + } + + optionsConfig.Options = append(optionsConfig.Options, option) + } + job.OptionsConfig = optionsConfig + } + + if d.Get("node_filter_query").(string) != "" { + job.NodeFilter = &rundeck.JobNodeFilter{ + ExcludePrecedence: d.Get("node_filter_exclude_precedence").(bool), + Query: d.Get("node_filter_query").(string), + } + } + + return job, nil +} + +func jobToResourceData(job *rundeck.JobDetail, d *schema.ResourceData) error { + + d.SetId(job.ID) + d.Set("id", job.ID) + d.Set("name", job.Name) + d.Set("group_name", job.GroupName) + d.Set("project_name", job.ProjectName) + d.Set("description", job.Description) + d.Set("log_level", job.LogLevel) + d.Set("allow_concurrent_executions", job.AllowConcurrentExecutions) + if job.Dispatch != nil { + d.Set("max_thread_count", job.Dispatch.MaxThreadCount) + d.Set("continue_on_error", job.Dispatch.ContinueOnError) + d.Set("rank_attribute", job.Dispatch.RankAttribute) + d.Set("rank_order", job.Dispatch.RankOrder) + } else { + d.Set("max_thread_count", nil) + d.Set("continue_on_error", nil) + d.Set("rank_attribute", nil) + d.Set("rank_order", nil) + } + + d.Set("node_filter_query", nil) + d.Set("node_filter_exclude_precedence", nil) + if job.NodeFilter != nil { + d.Set("node_filter_query", job.NodeFilter.Query) + d.Set("node_filter_exclude_precedence", job.NodeFilter.ExcludePrecedence) + } + + optionConfigsI := []interface{}{} + if job.OptionsConfig != nil { + d.Set("preserve_options_order", job.OptionsConfig.PreserveOrder) + for _, option := range job.OptionsConfig.Options { + optionConfigI := map[string]interface{}{ + "name": option.Name, + "default_value": option.DefaultValue, + "value_choices": option.ValueChoices, + "value_choices_url": option.ValueChoicesURL, + "require_predefined_choice": option.RequirePredefinedChoice, + "validation_regex": option.ValidationRegex, + "decription": option.Description, + "required": option.IsRequired, + "allow_multiple_values": option.AllowsMultipleValues, + "multi_value_delimeter": option.MultiValueDelimiter, + "obscure_input": option.ObscureInput, + "exposed_to_scripts": option.ValueIsExposedToScripts, + } + optionConfigsI = append(optionConfigsI, optionConfigI) + } + } + d.Set("option", optionConfigsI) + + commandConfigsI := []interface{}{} + if job.CommandSequence != nil { + d.Set("command_ordering_strategy", job.CommandSequence.OrderingStrategy) + for _, command := range job.CommandSequence.Commands { + commandConfigI := map[string]interface{}{ + "shell_command": command.ShellCommand, + "inline_script": command.Script, + "script_file": command.ScriptFile, + "script_file_args": command.ScriptFileArgs, + } + + if command.Job != nil { + commandConfigI["job"] = []interface{}{ + map[string]interface{}{ + "name": command.Job.Name, + "group_name": command.Job.GroupName, + "run_for_each_node": command.Job.RunForEachNode, + "args": command.Job.Arguments, + }, + } + } + + if command.StepPlugin != nil { + commandConfigI["step_plugin"] = []interface{}{ + map[string]interface{}{ + "type": command.StepPlugin.Type, + "config": map[string]string(command.StepPlugin.Config), + }, + } + } + + if command.NodeStepPlugin != nil { + commandConfigI["node_step_plugin"] = []interface{}{ + map[string]interface{}{ + "type": command.NodeStepPlugin.Type, + "config": map[string]string(command.NodeStepPlugin.Config), + }, + } + } + + commandConfigsI = append(commandConfigsI, commandConfigI) + } + } + d.Set("command", commandConfigsI) + + return nil +} diff --git a/builtin/providers/rundeck/resource_job_test.go b/builtin/providers/rundeck/resource_job_test.go new file mode 100644 index 000000000..66482ef0f --- /dev/null +++ b/builtin/providers/rundeck/resource_job_test.go @@ -0,0 +1,103 @@ +package rundeck + +import ( + "fmt" + "testing" + + "github.com/apparentlymart/go-rundeck-api/rundeck" + + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +func TestAccJob_basic(t *testing.T) { + var job rundeck.JobDetail + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccJobCheckDestroy(&job), + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccJobConfig_basic, + Check: resource.ComposeTestCheckFunc( + testAccJobCheckExists("rundeck_job.test", &job), + func(s *terraform.State) error { + if expected := "basic-job"; job.Name != expected { + return fmt.Errorf("wrong name; expected %v, got %v", expected, job.Name) + } + return nil + }, + ), + }, + }, + }) +} + +func testAccJobCheckDestroy(job *rundeck.JobDetail) resource.TestCheckFunc { + return func(s *terraform.State) error { + client := testAccProvider.Meta().(*rundeck.Client) + _, err := client.GetJob(job.ID) + if err == nil { + return fmt.Errorf("key still exists") + } + if _, ok := err.(*rundeck.NotFoundError); !ok { + return fmt.Errorf("got something other than NotFoundError (%v) when getting key", err) + } + + return nil + } +} + +func testAccJobCheckExists(rn string, job *rundeck.JobDetail) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[rn] + if !ok { + return fmt.Errorf("resource not found: %s", rn) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("job id not set") + } + + client := testAccProvider.Meta().(*rundeck.Client) + gotJob, err := client.GetJob(rs.Primary.ID) + if err != nil { + return fmt.Errorf("error getting job details: %s", err) + } + + *job = *gotJob + + return nil + } +} + +const testAccJobConfig_basic = ` +resource "rundeck_project" "test" { + name = "terraform-acc-test-job" + description = "parent project for job acceptance tests" + resource_model_source { + type = "file" + config = { + format = "resourcexml" + file = "/tmp/terraform-acc-tests.xml" + } + } +} +resource "rundeck_job" "test" { + project_name = "${rundeck_project.test.name}" + name = "basic-job" + description = "A basic job" + node_filter_query = "example" + allow_concurrent_executions = 1 + max_thread_count = 1 + rank_order = "ascending" + option { + name = "foo" + default_value = "bar" + } + command { + shell_command = "echo Hello World" + } +} +` From 1c8d0cdac75c5feacda834783740af09d0b03ba7 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Mon, 17 Aug 2015 10:18:56 -0700 Subject: [PATCH 6/6] Rundeck provider documentation for the website. --- website/source/assets/stylesheets/_docs.scss | 1 + .../providers/rundeck/index.html.markdown | 75 ++++++++ .../docs/providers/rundeck/r/job.html.md | 166 ++++++++++++++++++ .../providers/rundeck/r/private_key.html.md | 39 ++++ .../docs/providers/rundeck/r/project.html.md | 90 ++++++++++ .../providers/rundeck/r/public_key.html.md | 51 ++++++ website/source/layouts/docs.erb | 4 + website/source/layouts/rundeck.erb | 35 ++++ 8 files changed, 461 insertions(+) create mode 100644 website/source/docs/providers/rundeck/index.html.markdown create mode 100644 website/source/docs/providers/rundeck/r/job.html.md create mode 100644 website/source/docs/providers/rundeck/r/private_key.html.md create mode 100644 website/source/docs/providers/rundeck/r/project.html.md create mode 100644 website/source/docs/providers/rundeck/r/public_key.html.md create mode 100644 website/source/layouts/rundeck.erb diff --git a/website/source/assets/stylesheets/_docs.scss b/website/source/assets/stylesheets/_docs.scss index 0cf800715..799b631a0 100755 --- a/website/source/assets/stylesheets/_docs.scss +++ b/website/source/assets/stylesheets/_docs.scss @@ -20,6 +20,7 @@ body.layout-google, body.layout-heroku, body.layout-mailgun, body.layout-openstack, +body.layout-rundeck, body.layout-template, body.layout-docs, body.layout-downloads, diff --git a/website/source/docs/providers/rundeck/index.html.markdown b/website/source/docs/providers/rundeck/index.html.markdown new file mode 100644 index 000000000..e948ca4e3 --- /dev/null +++ b/website/source/docs/providers/rundeck/index.html.markdown @@ -0,0 +1,75 @@ +--- +layout: "rundeck" +page_title: "Provider: Rundeck" +sidebar_current: "docs-rundeck-index" +description: |- + The Rundeck provider configures projects, jobs and keys in Rundeck. +--- + +# Rundeck Provider + +The Rundeck provider allows Terraform to create and configure Projects, +Jobs and Keys in [Rundeck](http://rundeck.org/). Rundeck is a tool +for runbook automation and execution of arbitrary management tasks, +allowing operators to avoid logging in to individual machines directly +via SSH. + +The provider configuration block accepts the following arguments: + +* ``url`` - (Required) The root URL of a Rundeck server. May alternatively be set via the + ``RUNDECK_URL`` environment variable. + +* ``auth_token`` - (Required) The API auth token to use when making requests. May alternatively + be set via the ``RUNDECK_AUTH_TOKEN`` environment variable. + +* ``allow_unverified_ssl`` - (Optional) Boolean that can be set to ``true`` to disable SSL + certificate verification. This should be used with care as it could allow an attacker to + intercept your auth token. + +Use the navigation to the left to read about the available resources. + +## Example Usage + +``` +provider "rundeck" { + url = "http://rundeck.example.com/" + auth_token = "abcd1234" +} + +resource "rundeck_project" "anvils" { + name = "anvils" + description = "Application for managing Anvils" + + ssh_key_storage_path = "${rundeck_private_key.anvils.path}" + + resource_model_source { + type = "file" + config = { + format = "resourcexml" + # This path is interpreted on the Rundeck server. + file = "/var/rundeck/projects/anvils/resources.xml" + } + } +} + +resource "rundeck_job" "bounceweb" { + name = "Bounce Web Servers" + project_name = "${rundeck_project.anvils.name}" + node_filter_query = "tags: web" + description = "Restart the service daemons on all the web servers" + + command { + shell_command = "sudo service anvils restart" + } +} + +resource "rundeck_public_key" "anvils" { + path = "anvils/id_rsa.pub" + key_material = "ssh-rsa yada-yada-yada" +} + +resource "rundeck_private_key" "anvils" { + path = "anvils/id_rsa" + key_material_file = "${path.module}/id_rsa.pub" +} +``` diff --git a/website/source/docs/providers/rundeck/r/job.html.md b/website/source/docs/providers/rundeck/r/job.html.md new file mode 100644 index 000000000..a728a32bb --- /dev/null +++ b/website/source/docs/providers/rundeck/r/job.html.md @@ -0,0 +1,166 @@ +--- +layout: "rundeck" +page_title: "Rundeck: rundeck_job" +sidebar_current: "docs-rundeck-resource-job" +description: |- + The rundeck_job resource allows Rundeck jobs to be managed by Terraform. +--- + +# rundeck\_job + +The job resource allows Rundeck jobs to be managed by Terraform. In Rundeck a job is a particular +named set of steps that can be executed against one or more of the nodes configured for its +associated project. + +Each job belongs to a project. A project can be created with the `rundeck_project` resource. + +## Example Usage + +``` +resource "rundeck_job" "bounceweb" { + name = "Bounce Web Servers" + project_name = "anvils" + node_filter_query = "tags: web" + description = "Restart the service daemons on all the web servers" + + command { + shell_command = "sudo service anvils restart" + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `name` - (Required) The name of the job, used to describe the job in the Rundeck UI. + +* `description` - (Required) A longer description of the job, describing the job in the Rundeck UI. + +* `project_name` - (Required) The name of the project that this job should belong to. + +* `group_name` - (Optional) The name of a group within the project in which to place the job. + Setting this creates collapsable subcategories within the Rundeck UI's project job index. + +* `log_level` - (Optional) The log level that Rundeck should use for this job. Defaults to "INFO". + +* `allow_concurrent_executions` - (Optional) Boolean defining whether two or more executions of + this job can run concurrently. The default is `false`, meaning that jobs will only run + sequentially. + +* `max_thread_count` - (Optional) The maximum number of threads to use to execute this job, which + controls on how many nodes the commands can be run simulateneously. Defaults to 1, meaning that + the nodes will be visited sequentially. + +* `continue_on_error` - (Optional) Boolean defining whether Rundeck will continue to run + subsequent steps if any intermediate step fails. Defaults to `false`, meaning that execution + will stop and the execution will be considered to have failed. + +* `rank_attribute` - (Optional) The name of the attribute that will be used to decide in which + order the nodes will be visited while executing the job across multiple nodes. + +* `rank_order` - (Optional) Keyword deciding which direction the nodes are sorted in terms of + the chosen `rank_attribute`. May be either "ascending" (the default) or "descending". + +* `preserve_options_order`: (Optional) Boolean controlling whether the configured options will + be presented in their configuration order when shown in the Rundeck UI. The default is `false`, + which means that the options will be displayed in alphabetical order by name. + +* `command_ordering_strategy`: (Optional) The name of the strategy used to describe how to + traverse the matrix of nodes and commands. The default is "node-first", meaning that all commands + will be executed on a single node before moving on to the next. May also be set to "step-first", + meaning that a single step will be executed across all nodes before moving on to the next step. + +* `node_filter_query` - (Optional) A query string using + [Rundeck's node filter language](http://rundeck.org/docs/manual/node-filters.html#node-filter-syntax) + that defines which subset of the project's nodes will be used to execute this job. + +* `node_filter_exclude_precedence`: (Optional) Boolean controlling a deprecated Rundeck feature that controls + whether node exclusions take priority over inclusions. + +* `option`: (Optional) Nested block defining an option a user may set when executing this job. A + job may have any number of options. The structure of this nested block is described below. + +* `command`: (Required) Nested block defining one step in the job workflow. A job must have one or + more commands. The structure of this nested block is described below. + +`option` blocks have the following structure: + +* `name`: (Required) Unique name that will be shown in the UI when entering values and used as + a variable name for template substitutions. + +* `default_value`: (Optional) A default value for the option. + +* `value_choices`: (Optional) A list of strings giving a set of predefined values that the user + may choose from when entering a value for the option. + +* `value_choices_url`: (Optional) Can be used instead of `value_choices` to cause Rundeck to + obtain a list of choices dynamically by fetching this URL. + +* `require_predefined_choice`: (Optional) Boolean controlling whether the user is allowed to + enter values not included in the predefined set of choices (`false`, the default) or whether + a predefined choice is required (`true`). + +* `validation_regex`: (Optional) A regular expression that a provided value must match in order + to be accepted. + +* `description`: (Optional) A longer description of the option to be shown in the UI. + +* `required`: (Optional) Boolean defining whether the user must provide a value for the option. + Defaults to `false`. + +* `allow_multiple_values`: (Optional) Boolean defining whether the user may select multiple values + from the set of predefined values. Defaults to `false`, meaning that the user may choose only + one value. + +* `multi_value_delimeter`: (Optional) Delimeter used to join together multiple values into a single + string when `allow_multiple_values` is set and the user chooses multiple values. + +* `obscure_input`: (Optional) Boolean controlling whether the value of this option should be obscured + during entry and in execution logs. Defaults to `false`, but should be set to `true` when the + requested value is a password, private key or any other secret value. + +* `exposed_to_scripts`: (Optional) Boolean controlling whether the value of this option is available + to scripts executed by job commands. Defaults to `false`. + +`command` blocks must have any one of the following combinations of arguments as contents: + +* `shell_command` gives a single shell command to execute on the nodes. + +* `inline_script` gives a whole shell script, inline in the configuration, to execute on the nodes. + +* `script_file` and `script_file_args` together describe a script that is already pre-installed + on the nodes which is to be executed. + +* A `job` block, described below, causes another job within the same project to be executed as + a command. + +* A `step_plugin` block, described below, causes a step plugin to be executed as a command. + +* A `node_step_plugin` block, described below, causes a node step plugin to be executed once for + each node. + +A command's `job` block has the following structure: + +* `name`: (Required) The name of the job to execute. The target job must be in the same project + as the current job. + +* `group_name`: (Optional) The name of the group that the target job belongs to, if any. + +* `run_for_each_node`: (Optional) Boolean controlling whether the job is run only once (`false`, + the default) or whether it is run once for each node (`true`). + +* `args`: (Optional) A string giving the arguments to pass to the target job, using + [Rundeck's job arguments syntax](http://rundeck.org/docs/manual/jobs.html#job-reference-step). + +A command's `step_plugin` or `node_step_plugin` block both have the following structure: + +* `type`: (Required) The name of the plugin to execute. + +* `config`: (Optional) Map of arbitrary configuration parameters for the selected plugin. + +## Attributes Reference + +The following attribute is exported: + +* `id` - A unique identifier for the job. diff --git a/website/source/docs/providers/rundeck/r/private_key.html.md b/website/source/docs/providers/rundeck/r/private_key.html.md new file mode 100644 index 000000000..1850f588e --- /dev/null +++ b/website/source/docs/providers/rundeck/r/private_key.html.md @@ -0,0 +1,39 @@ +--- +layout: "rundeck" +page_title: "Rundeck: rundeck_private_key" +sidebar_current: "docs-rundeck-resource-private-key" +description: |- + The rundeck_private_key resource allows private keys to be stored in Rundeck's key store. +--- + +# rundeck\_private\_key + +The private key resource allows SSH private keys to be stored into Rundeck's key store. +The key store is where Rundeck keeps credentials that are needed to access the nodes on which +it runs commands. + +## Example Usage + +``` +resource "rundeck_private_key" "anvils" { + path = "anvils/id_rsa" + key_material = "${file(\"/id_rsa\")}" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `path` - (Required) The path within the key store where the key will be stored. + +* `key_material` - (Required) The private key material to store, serialized in any way that is + accepted by OpenSSH. + +The key material is hashed before it is stored in the state file, so sharing the resulting state +will not disclose the private key contents. + +## Attributes Reference + +Rundeck does not allow stored private keys to be retrieved via the API, so this resource does not +export any attributes. diff --git a/website/source/docs/providers/rundeck/r/project.html.md b/website/source/docs/providers/rundeck/r/project.html.md new file mode 100644 index 000000000..e8a7d3eba --- /dev/null +++ b/website/source/docs/providers/rundeck/r/project.html.md @@ -0,0 +1,90 @@ +--- +layout: "rundeck" +page_title: "Rundeck: rundeck_project" +sidebar_current: "docs-rundeck-resource-project" +description: |- + The rundeck_project resource allows Rundeck projects to be managed by Terraform. +--- + +# rundeck\_project + +The project resource allows Rundeck projects to be managed by Terraform. In Rundeck a project +is the container object for a set of jobs and the configuration for which servers those jobs +can be run on. + +## Example Usage + +``` +resource "rundeck_project" "anvils" { + name = "anvils" + description = "Application for managing Anvils" + + ssh_key_storage_path = "anvils/id_rsa" + + resource_model_source { + type = "file" + config = { + format = "resourcexml" + # This path is interpreted on the Rundeck server. + file = "/var/rundeck/projects/anvils/resources.xml" + } + } +} +``` + +Note that the above configuration assumes the existence of a ``resources.xml`` file in the +filesystem on the Rundeck server. The Rundeck provider does not itself support creating such a file, +but one way to place it would be to use the ``file`` provisioner to copy a configuration file +from the module directory. + +## Argument Reference + +The following arguments are supported: + +* `name` - (Required) The name of the project, used both in the UI and to uniquely identify + the project. Must therefore be unique across a single Rundeck installation. + +* `resource_model_source` - (Required) Nested block instructing Rundeck on how to determine the + set of resources (nodes) for this project. The nested block structure is described below. + +* `description` - (Optional) A description of the project, to be displayed in the Rundeck UI. + Defaults to "Managed by Terraform". + +* `default_node_file_copier_plugin` - (Optional) The name of a plugin to use to copy files onto + nodes within this project. Defaults to `jsch-scp`, which uses the "Secure Copy" protocol + to send files over SSH. + +* `default_node_executor_plugin` - (Optional) The name of a plugin to use to run commands on + nodes within this project. Defaults to `jsch-ssh`, which uses the SSH protocol to access the + nodes. + +* `ssh_authentication_type` - (Optional) When the SSH-based file copier and executor plugins are + used, the type of SSH authentication to use. Defaults to `privateKey`. + +* `ssh_key_storage_path` - (Optional) When the SSH-based file copier and executor plugins are + used, the location within Rundeck's key store where the SSH private key can be found. Private + keys can be uploaded to rundeck using the `rundeck_private_key` resource. + +* `ssh_key_file_path` - (Optional) Like `ssh_key_storage_path` except that the key is read from + the Rundeck server's local filesystem, rather than from the key store. + +* `extra_config` - (Optional) Behind the scenes a Rundeck project is really an arbitrary set of + key/value pairs. This map argument allows setting any configuration properties that aren't + explicitly supported by the other arguments described above, but due to limitations of Terraform + the key names must be written with slashes in place of dots. Do not use this argument to set + properties that the above arguments set, or undefined behavior will result. + +`resource_model_source` blocks have the following nested arguments: + +* `type` - (Required) The name of the resource model plugin to use. + +* `config` - (Required) Map of arbitrary configuration properties for the selected resource model + plugin. + +## Attributes Reference + +The following attributes are exported: + +* `name` - The unique name that identifies the project, as set in the arguments. +* `ui_url` - The URL of the index page for this project in the Rundeck UI. + diff --git a/website/source/docs/providers/rundeck/r/public_key.html.md b/website/source/docs/providers/rundeck/r/public_key.html.md new file mode 100644 index 000000000..13ddca606 --- /dev/null +++ b/website/source/docs/providers/rundeck/r/public_key.html.md @@ -0,0 +1,51 @@ +--- +layout: "rundeck" +page_title: "Rundeck: rundeck_public_key" +sidebar_current: "docs-rundeck-resource-public-key" +description: |- + The rundeck_public_key resource allows public keys to be stored in Rundeck's key store. +--- + +# rundeck\_public\_key + +The public key resource allows SSH public keys to be stored into Rundeck's key store. +The key store is where Rundeck keeps credentials that are needed to access the nodes on which +it runs commands. + +This resource also allows the retrieval of an existing public key from the store, so that it +may be used in the configuration of other resources such as ``aws_key_pair``. + +## Example Usage + +``` +resource "rundeck_public_key" "anvils" { + path = "anvils/id_rsa.pub" + key_material = "ssh-rsa yada-yada-yada" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `path` - (Required) The path within the key store where the key will be stored. By convention + this path name normally ends with ".pub" and otherwise has the same name as the associated + private key. + +* `key_material` - (Optional) The public key string to store, serialized in any way that is accepted + by OpenSSH. If this is not included, ``key_material`` becomes an attribute that can be used + to read the already-existing key material in the Rundeck store. + +The key material is included inline as a string, which is consistent with the way a public key +is provided to the `aws_key_pair`, `cloudstack_ssh_keypair`, `digitalocean_ssh_key` and +`openstack_compute_keypair_v2` resources. This means the `key_material` argument can be populated +from the interpolation of the `public_key` attribute of such a keypair resource, or vice-versa. + +## Attributes Reference + +The following attributes are exported: + +* `url` - The URL at which the key material can be retrieved from the key store by other clients. + +* `key_material` - If `key_material` is omitted in the configuration, it becomes an attribute that + exposes the key material already stored at the given `path`. diff --git a/website/source/layouts/docs.erb b/website/source/layouts/docs.erb index 6e650eb18..63a775e86 100644 --- a/website/source/layouts/docs.erb +++ b/website/source/layouts/docs.erb @@ -177,6 +177,10 @@ OpenStack + > + Rundeck + + > Template diff --git a/website/source/layouts/rundeck.erb b/website/source/layouts/rundeck.erb new file mode 100644 index 000000000..6facc36f5 --- /dev/null +++ b/website/source/layouts/rundeck.erb @@ -0,0 +1,35 @@ +<% wrap_layout :inner do %> + <% content_for :sidebar do %> + + <% end %> + + <%= yield %> +<% end %>