Skip to content

Commit

Permalink
Adding jenkins_credential_username resource (taiidani#15)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
taiidani committed Aug 23, 2020
1 parent b8c500f commit aacbf44
Show file tree
Hide file tree
Showing 6 changed files with 358 additions and 2 deletions.
33 changes: 33 additions & 0 deletions docs/resources/credential_username.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions example/credentials.tf
Original file line number Diff line number Diff line change
@@ -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"
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
5 changes: 3 additions & 2 deletions jenkins/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
208 changes: 208 additions & 0 deletions jenkins/resource_jenkins_credential_username.go
Original file line number Diff line number Diff line change
@@ -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 \"[<folder>/]<domain>/<name>\"")
}

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)
}
100 changes: 100 additions & 0 deletions jenkins/resource_jenkins_credential_username_test.go
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit aacbf44

Please sign in to comment.