From aacbf446e2680c4a9397b24ad0b7cdba1ff65164 Mon Sep 17 00:00:00 2001 From: Ryan Nixon Date: Sun, 23 Aug 2020 15:07:38 -0700 Subject: [PATCH] Adding jenkins_credential_username resource (#15) * Adding jenkins_credential_username resource * Exposing credential ID to user * Merge coverage reports * Test updates * Documentation * Don't need the "2"s anymore * Add warning about folder templates * Validate folder name exists --- docs/resources/credential_username.md | 33 +++ example/credentials.tf | 13 ++ go.mod | 1 + jenkins/provider.go | 5 +- .../resource_jenkins_credential_username.go | 208 ++++++++++++++++++ ...source_jenkins_credential_username_test.go | 100 +++++++++ 6 files changed, 358 insertions(+), 2 deletions(-) create mode 100644 docs/resources/credential_username.md create mode 100644 example/credentials.tf create mode 100644 jenkins/resource_jenkins_credential_username.go create mode 100644 jenkins/resource_jenkins_credential_username_test.go diff --git a/docs/resources/credential_username.md b/docs/resources/credential_username.md new file mode 100644 index 0000000..05d57e3 --- /dev/null +++ b/docs/resources/credential_username.md @@ -0,0 +1,33 @@ +# jenkins_credential_username Resource + +Manages a username credential within Jenkins. This username may then be referenced within jobs that are created. + +~> The "password" property may leave plain-text passwords in your state file. If using the property to manage the password in Terraform, ensure that your state file is properly secured and encrypted at rest. + +~> When using this resource within a folder context it can conflict with the [folder resource](folder) template. When using these in combination you may need to add a lifecycle `ignore_changes` rule to the folder's `template` property. + +## Example Usage + +```hcl +resource jenkins_credential_username example { + name = "example-username" + username = "example" + password = "super-secret" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `name` - (Required) The name of the credentials being created. This maps to the ID property within Jenkins, and cannot be changed once set. +* `domain` - (Optional) The domain store to place the credentials into. If not set will default to the global credentials store. +* `folder` - (Optional) The folder namespace to store the credentials in. If not set will default to global Jenkins credentials. +* `scope` - (Optional) The visibility of the credentials to Jenkins agents. This must be set to either "GLOBAL" or "SYSTEM". If not set will default to "GLOBAL". +* `description` - (Optional) A human readable description of the credentials being stored. +* `username` - (Required) The username to be associated with the credentials. +* `password` - (Optional) The password to be associated with the credentials. If empty then the password property will become unmanaged and expected to be set manually within Jenkins. If set then the password will be updated only upon changes -- if the password is set manually within Jenkins then it will not reconcile this drift until the next time the password property is changed. + +## Attribute Reference + +All arguments above are exported. diff --git a/example/credentials.tf b/example/credentials.tf new file mode 100644 index 0000000..6ba3de4 --- /dev/null +++ b/example/credentials.tf @@ -0,0 +1,13 @@ +resource jenkins_credential_username global { + name = "global-username" + username = "foo" + # Passwords may be unmanaged + # password = "barsoom" +} + +resource jenkins_credential_username folder { + name = "folder-username" + folder = jenkins_folder.example.name + username = "folder-foo" + password = "barsoom" +} diff --git a/go.mod b/go.mod index dc00b74..70679db 100644 --- a/go.mod +++ b/go.mod @@ -5,5 +5,6 @@ go 1.14 require ( github.com/aws/aws-sdk-go v1.30.12 // indirect github.com/bndr/gojenkins v1.0.2-0.20200401074816-65ee8c9388b5 + github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 github.com/hashicorp/terraform-plugin-sdk/v2 v2.0.0 ) diff --git a/jenkins/provider.go b/jenkins/provider.go index 8724d20..afef302 100644 --- a/jenkins/provider.go +++ b/jenkins/provider.go @@ -39,8 +39,9 @@ func Provider() *schema.Provider { }, ResourcesMap: map[string]*schema.Resource{ - "jenkins_folder": resourceJenkinsFolder(), - "jenkins_job": resourceJenkinsJob(), + "jenkins_credential_username": resourceJenkinsCredentialUsername(), + "jenkins_folder": resourceJenkinsFolder(), + "jenkins_job": resourceJenkinsJob(), }, ConfigureContextFunc: configureProvider, diff --git a/jenkins/resource_jenkins_credential_username.go b/jenkins/resource_jenkins_credential_username.go new file mode 100644 index 0000000..d284757 --- /dev/null +++ b/jenkins/resource_jenkins_credential_username.go @@ -0,0 +1,208 @@ +package jenkins + +import ( + "context" + "fmt" + "strings" + + jenkins "github.com/bndr/gojenkins" + "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +var supportedCredentialScopes = []string{"SYSTEM", "GLOBAL"} + +func resourceJenkinsCredentialUsername() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceJenkinsCredentialUsernameCreate, + ReadContext: resourceJenkinsCredentialUsernameRead, + UpdateContext: resourceJenkinsCredentialUsernameUpdate, + DeleteContext: resourceJenkinsCredentialUsernameDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceJenkinsCredentialUsernameImport, + }, + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Description: "The identifier assigned to the credentials.", + Required: true, + ForceNew: true, + }, + "domain": { + Type: schema.TypeString, + Description: "The domain namespace that the credentials will be added to.", + Optional: true, + Default: "_", + // In-place updates should be possible, but gojenkins does not support move operations + ForceNew: true, + }, + "folder": { + Type: schema.TypeString, + Description: "The folder namespace that the credentials will be added to.", + Optional: true, + ForceNew: true, + }, + "scope": { + Type: schema.TypeString, + Description: "The Jenkins scope assigned to the credentials.", + Optional: true, + Default: "GLOBAL", + ValidateDiagFunc: validateCredentialScope, + }, + "description": { + Type: schema.TypeString, + Description: "The credentials descriptive text.", + Optional: true, + Default: "Managed by Terraform", + }, + "username": { + Type: schema.TypeString, + Description: "The credentials user username.", + Required: true, + }, + "password": { + Type: schema.TypeString, + Description: "The credentials user password. If left empty will be unmanaged.", + Optional: true, + Sensitive: true, + }, + }, + } +} + +func resourceJenkinsCredentialUsernameCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(jenkinsClient) + cm := client.Credentials() + + // Validate that the folder exists + cm.Folder = d.Get("folder").(string) + if cm.Folder != "" { + if _, err := client.GetJob(formatJobName(cm.Folder)); err != nil { + return diag.Errorf("Invalid folder name '%s' specified: %s", cm.Folder, err) + } + } + + cred := jenkins.UsernameCredentials{ + ID: d.Get("name").(string), + Scope: d.Get("scope").(string), + Description: d.Get("description").(string), + Username: d.Get("username").(string), + Password: d.Get("password").(string), + } + + domain := d.Get("domain").(string) + err := cm.Add(domain, cred) + if err != nil { + return diag.Errorf("Could not create username credentials: %s", err) + } + + d.SetId(generateCredentialID(cm.Folder, cred.ID)) + return resourceJenkinsCredentialUsernameRead(ctx, d, meta) +} + +func resourceJenkinsCredentialUsernameRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + cm := meta.(jenkinsClient).Credentials() + cm.Folder = d.Get("folder").(string) + + cred := jenkins.UsernameCredentials{} + err := cm.GetSingle( + d.Get("domain").(string), + d.Get("name").(string), + &cred, + ) + + if err != nil { + if strings.HasSuffix(err.Error(), "404") { + // Job does not exist + d.SetId("") + return nil + } + + return diag.Errorf("Could not read username credentials: %s", err) + } + + d.SetId(generateCredentialID(cm.Folder, cred.ID)) + d.Set("scope", cred.Scope) + d.Set("description", cred.Description) + d.Set("username", cred.Username) + // NOTE: We are NOT setting the password here, as the password returned by GetSingle is garbage + // Password only applies to Create/Update operations if the "password" property is non-empty + + return nil +} + +func resourceJenkinsCredentialUsernameUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + cm := meta.(jenkinsClient).Credentials() + cm.Folder = d.Get("folder").(string) + + domain := d.Get("domain").(string) + cred := jenkins.UsernameCredentials{ + ID: d.Get("name").(string), + Scope: d.Get("scope").(string), + Description: d.Get("description").(string), + Username: d.Get("username").(string), + } + + // Only enforce the password if it is non-empty + if d.Get("password").(string) != "" { + cred.Password = d.Get("password").(string) + } + + err := cm.Update(domain, d.Get("name").(string), &cred) + if err != nil { + return diag.Errorf("Could not update username credentials: %s", err) + } + + d.SetId(generateCredentialID(cm.Folder, cred.ID)) + return resourceJenkinsCredentialUsernameRead(ctx, d, meta) +} + +func resourceJenkinsCredentialUsernameDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + cm := meta.(jenkinsClient).Credentials() + cm.Folder = d.Get("folder").(string) + + err := cm.Delete( + d.Get("domain").(string), + d.Get("name").(string), + ) + if err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceJenkinsCredentialUsernameImport(ctx context.Context, d *schema.ResourceData, m interface{}) ([]*schema.ResourceData, error) { + ret := []*schema.ResourceData{d} + + splitID := strings.Split(d.Id(), "/") + if len(splitID) < 2 { + return ret, fmt.Errorf("Import ID was improperly formatted. Imports need to be in the format \"[/]/\"") + } + + name := splitID[len(splitID)-1] + d.Set("name", name) + + domain := splitID[len(splitID)-2] + d.Set("domain", domain) + + folder := strings.Trim(strings.Join(splitID[0:len(splitID)-2], "/"), "/") + d.Set("folder", folder) + + d.SetId(generateCredentialID(folder, name)) + return ret, nil +} + +func validateCredentialScope(v interface{}, p cty.Path) diag.Diagnostics { + for _, supported := range supportedCredentialScopes { + if v == supported { + return nil + } + } + return diag.Errorf("Invalid scope: %s. Supported scopes are: %s", v, strings.Join(supportedCredentialScopes, ", ")) +} + +func generateCredentialID(folder, name string) string { + return fmt.Sprintf("%s/%s", folder, name) +} diff --git a/jenkins/resource_jenkins_credential_username_test.go b/jenkins/resource_jenkins_credential_username_test.go new file mode 100644 index 0000000..e30a3aa --- /dev/null +++ b/jenkins/resource_jenkins_credential_username_test.go @@ -0,0 +1,100 @@ +package jenkins + +import ( + "fmt" + "testing" + + jenkins "github.com/bndr/gojenkins" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccJenkinsCredentialUsername_basic(t *testing.T) { + var cred jenkins.UsernameCredentials + // randString := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckJenkinsCredentialUsernameDestroy, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + resource jenkins_credential_username foo { + name = "test-username" + username = "foo" + password = "bar" + }`), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("jenkins_credential_username.foo", "id", "/test-username"), + testAccCheckJenkinsCredentialUsernameExists("jenkins_credential_username.foo", &cred), + ), + }, + { + // Update by adding description + Config: fmt.Sprintf(` + resource jenkins_credential_username foo { + name = "test-username" + description = "new-description" + username = "foo" + password = "bar" + }`), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("jenkins_credential_username.foo", "description", "new-description"), + testAccCheckJenkinsCredentialUsernameExists("jenkins_credential_username.foo", &cred), + testAccCheckJenkinsCredentialUsernameDescriptionUpdated(&cred, "new-description"), + ), + }, + }, + }) +} + +func testAccCheckJenkinsCredentialUsernameExists(resourceName string, cred *jenkins.UsernameCredentials) resource.TestCheckFunc { + return func(s *terraform.State) error { + client := testAccProvider.Meta().(jenkinsClient) + + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf(resourceName + " not found") + } + + if rs.Primary.ID == "" { + return fmt.Errorf("ID is not set") + } + + err := client.Credentials().GetSingle(rs.Primary.Attributes["domain"], rs.Primary.Attributes["name"], cred) + if err != nil { + return fmt.Errorf("Unable to retrieve credentials for %s: %w", rs.Primary.ID, err) + } + + return nil + } +} + +func testAccCheckJenkinsCredentialUsernameDescriptionUpdated(cred *jenkins.UsernameCredentials, description string) resource.TestCheckFunc { + return func(s *terraform.State) error { + if cred.Description != description { + return fmt.Errorf("Description was not set") + } + + return nil + } +} + +func testAccCheckJenkinsCredentialUsernameDestroy(s *terraform.State) error { + client := testAccProvider.Meta().(jenkinsClient) + + for _, rs := range s.RootModule().Resources { + if rs.Type != "jenkins_credential_username" { + continue + } + + cred := jenkins.UsernameCredentials{} + err := client.Credentials().GetSingle(rs.Primary.Meta["domain"].(string), rs.Primary.Meta["name"].(string), &cred) + if err == nil { + return fmt.Errorf("Credentials %s still exists", rs.Primary.ID) + } + } + + return nil +}