Skip to content

Commit

Permalink
[Feature] Add databricks_credential resource (#4219)
Browse files Browse the repository at this point in the history
## Changes
- Add `databricks_credential` resource that represents [service
credential](https://docs.databricks.com/en/connect/unity-catalog/cloud-services/service-credentials.html)

Resolves #4214 

## Tests
<!-- 
How is this tested? Please see the checklist below and also describe any
other relevant tests
-->

- [x] `make test` run locally
- [x] relevant change in `docs/` folder
- [x] covered with integration tests in `internal/acceptance`
- [x] relevant acceptance tests are passing
- [x] using Go SDK

---------

Co-authored-by: Farruco Sanjurjo <farruco.sanjurjo@databricks.com>
Co-authored-by: Alex Ott <alexey.ott@databricks.com>
Co-authored-by: Farruco Sanjurjo <madtrick@users.noreply.github.com>
  • Loading branch information
4 people authored Nov 28, 2024
1 parent 9ea68e9 commit f119680
Show file tree
Hide file tree
Showing 10 changed files with 513 additions and 2 deletions.
1 change: 1 addition & 0 deletions catalog/permissions/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ func (sm SecurableMapping) Id(d *schema.ResourceData) string {
// Omitting provider as a reserved keyword
var Mappings = SecurableMapping{
"catalog": catalog.SecurableType("catalog"),
"credential": catalog.SecurableType("credential"),
"foreign_connection": catalog.SecurableType("connection"),
"external_location": catalog.SecurableType("external_location"),
"function": catalog.SecurableType("function"),
Expand Down
159 changes: 159 additions & 0 deletions catalog/resource_credential.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package catalog

import (
"context"

"github.com/databricks/databricks-sdk-go/service/catalog"
"github.com/databricks/terraform-provider-databricks/catalog/bindings"
"github.com/databricks/terraform-provider-databricks/common"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

var credentialSchema = common.StructToSchema(catalog.CredentialInfo{},
func(m map[string]*schema.Schema) map[string]*schema.Schema {
var alofServiceCreds = []string{"aws_iam_role", "azure_managed_identity", "azure_service_principal"}
for _, cred := range alofServiceCreds {
common.CustomizeSchemaPath(m, cred).SetExactlyOneOf(alofServiceCreds)
}

for _, required := range []string{"name", "purpose"} {
common.CustomizeSchemaPath(m, required).SetRequired()
}

for _, computed := range []string{"id", "created_at", "created_by", "full_name", "isolation_mode",
"metastore_id", "owner", "updated_at", "updated_by", "used_for_managed_storage"} {
common.CustomizeSchemaPath(m, computed).SetComputed()
}

common.MustSchemaPath(m, "aws_iam_role", "external_id").Computed = true
common.MustSchemaPath(m, "aws_iam_role", "unity_catalog_iam_arn").Computed = true
common.MustSchemaPath(m, "azure_managed_identity", "credential_id").Computed = true
common.MustSchemaPath(m, "azure_service_principal", "client_secret").Sensitive = true

m["force_destroy"] = &schema.Schema{
Type: schema.TypeBool,
Optional: true,
}
m["force_update"] = &schema.Schema{
Type: schema.TypeBool,
Optional: true,
}
m["skip_validation"] = &schema.Schema{
Type: schema.TypeBool,
Optional: true,
DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool {
return old == "false" && new == "true"
},
}
m["credential_id"] = &schema.Schema{
Type: schema.TypeString,
Computed: true,
}
m["name"].DiffSuppressFunc = common.EqualFoldDiffSuppress
return m
})

func ResourceCredential() common.Resource {
return common.Resource{
Schema: credentialSchema,
Create: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error {
w, err := c.WorkspaceClient()
if err != nil {
return err
}
var create catalog.CreateCredentialRequest
common.DataToStructPointer(d, credentialSchema, &create)
cred, err := w.Credentials.CreateCredential(ctx, create)
if err != nil {
return err
}
d.SetId(cred.Name)

// Update owner or isolation mode if it is provided
if !updateRequired(d, []string{"owner", "isolation_mode"}) {
return nil
}

var update catalog.UpdateCredentialRequest
common.DataToStructPointer(d, credentialSchema, &update)
update.NameArg = d.Id()
_, err = w.Credentials.UpdateCredential(ctx, update)
if err != nil {
return err
}

// Bind the current workspace if the credential is isolated, otherwise the read will fail
return bindings.AddCurrentWorkspaceBindings(ctx, d, w, cred.Name, catalog.UpdateBindingsSecurableTypeServiceCredential)
},
Read: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error {
w, err := c.WorkspaceClient()
if err != nil {
return err
}
cred, err := w.Credentials.GetCredentialByNameArg(ctx, d.Id())
if err != nil {
return err
}
d.Set("credential_id", cred.Id)
return common.StructToData(cred, credentialSchema, d)
},
Update: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error {
force := d.Get("force_update").(bool)
w, err := c.WorkspaceClient()
if err != nil {
return err
}
var updateCredRequest catalog.UpdateCredentialRequest
common.DataToStructPointer(d, credentialSchema, &updateCredRequest)
updateCredRequest.NameArg = d.Id()
updateCredRequest.Force = force

if d.HasChange("owner") {
_, err = w.Credentials.UpdateCredential(ctx, catalog.UpdateCredentialRequest{
NameArg: updateCredRequest.NameArg,
Owner: updateCredRequest.Owner,
})
if err != nil {
return err
}
}

if !d.HasChangeExcept("owner") {
return nil
}
if d.HasChange("read_only") {
updateCredRequest.ForceSendFields = append(updateCredRequest.ForceSendFields, "ReadOnly")
}

updateCredRequest.Owner = ""
_, err = w.Credentials.UpdateCredential(ctx, updateCredRequest)
if err != nil {
if d.HasChange("owner") {
// Rollback
old, new := d.GetChange("owner")
_, rollbackErr := w.Credentials.UpdateCredential(ctx, catalog.UpdateCredentialRequest{
NameArg: updateCredRequest.NameArg,
Owner: old.(string),
})
if rollbackErr != nil {
return common.OwnerRollbackError(err, rollbackErr, old.(string), new.(string))
}
}
return err
}
// Bind the current workspace if the credential is isolated, otherwise the read will fail
return bindings.AddCurrentWorkspaceBindings(ctx, d, w, updateCredRequest.NameArg, catalog.UpdateBindingsSecurableTypeServiceCredential)
},
Delete: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error {
force := d.Get("force_destroy").(bool)
w, err := c.WorkspaceClient()
if err != nil {
return err
}
return w.Credentials.DeleteCredential(ctx, catalog.DeleteCredentialRequest{
NameArg: d.Id(),
Force: force,
})
},
}
}
158 changes: 158 additions & 0 deletions catalog/resource_credential_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package catalog

import (
"testing"

"github.com/databricks/databricks-sdk-go/experimental/mocks"
"github.com/databricks/databricks-sdk-go/service/catalog"
"github.com/databricks/terraform-provider-databricks/qa"
"github.com/stretchr/testify/mock"
)

func TestCredentialsCornerCases(t *testing.T) {
qa.ResourceCornerCases(t, ResourceCredential())
}

func TestCreateCredential(t *testing.T) {
qa.ResourceFixture{
MockWorkspaceClientFunc: func(w *mocks.MockWorkspaceClient) {
e := w.GetMockCredentialsAPI().EXPECT()
e.CreateCredential(mock.Anything, catalog.CreateCredentialRequest{
Name: "a",
AwsIamRole: &catalog.AwsIamRole{
RoleArn: "def",
},
Comment: "c",
Purpose: "SERVICE",
}).Return(&catalog.CredentialInfo{
Name: "a",
AwsIamRole: &catalog.AwsIamRole{
RoleArn: "def",
},
Purpose: "SERVICE",
Comment: "c",
}, nil)
e.GetCredentialByNameArg(mock.Anything, "a").Return(&catalog.CredentialInfo{
Name: "a",
AwsIamRole: &catalog.AwsIamRole{
RoleArn: "def",
ExternalId: "123",
},
Purpose: "SERVICE",
MetastoreId: "d",
Id: "1234-5678",
Owner: "f",
IsolationMode: "ISOLATION_MODE_ISOLATED",
}, nil)
},
Resource: ResourceCredential(),
Create: true,
HCL: `
name = "a"
aws_iam_role {
role_arn = "def"
}
purpose = "SERVICE"
comment = "c"
`,
}.ApplyAndExpectData(t, map[string]any{
"aws_iam_role.0.external_id": "123",
"aws_iam_role.0.role_arn": "def",
"name": "a",
"purpose": "SERVICE",
})
}

func TestCreateIsolatedCredential(t *testing.T) {
qa.ResourceFixture{
MockWorkspaceClientFunc: func(w *mocks.MockWorkspaceClient) {
e := w.GetMockCredentialsAPI().EXPECT()
e.CreateCredential(mock.Anything, catalog.CreateCredentialRequest{
Name: "a",
AwsIamRole: &catalog.AwsIamRole{
RoleArn: "def",
},
Comment: "c",
Purpose: "SERVICE",
}).Return(&catalog.CredentialInfo{
Name: "a",
AwsIamRole: &catalog.AwsIamRole{
RoleArn: "def",
ExternalId: "123",
},
Purpose: "SERVICE",
MetastoreId: "d",
Id: "1234-5678",
Owner: "f",
}, nil)
e.UpdateCredential(mock.Anything, catalog.UpdateCredentialRequest{
NameArg: "a",
AwsIamRole: &catalog.AwsIamRole{
RoleArn: "def",
},
Comment: "c",
IsolationMode: "ISOLATION_MODE_ISOLATED",
}).Return(&catalog.CredentialInfo{
Name: "a",
AwsIamRole: &catalog.AwsIamRole{
RoleArn: "def",
ExternalId: "123",
},
Purpose: "SERVICE",
MetastoreId: "d",
Id: "1234-5678",
Owner: "f",
IsolationMode: "ISOLATION_MODE_ISOLATED",
}, nil)
w.GetMockMetastoresAPI().EXPECT().Current(mock.Anything).Return(&catalog.MetastoreAssignment{
MetastoreId: "e",
WorkspaceId: 123456789101112,
}, nil)
w.GetMockWorkspaceBindingsAPI().EXPECT().UpdateBindings(mock.Anything, catalog.UpdateWorkspaceBindingsParameters{
SecurableName: "a",
SecurableType: catalog.UpdateBindingsSecurableTypeServiceCredential,
Add: []catalog.WorkspaceBinding{
{
WorkspaceId: int64(123456789101112),
BindingType: catalog.WorkspaceBindingBindingTypeBindingTypeReadWrite,
},
},
}).Return(&catalog.WorkspaceBindingsResponse{
Bindings: []catalog.WorkspaceBinding{
{
WorkspaceId: int64(123456789101112),
BindingType: catalog.WorkspaceBindingBindingTypeBindingTypeReadWrite,
},
},
}, nil)
e.GetCredentialByNameArg(mock.Anything, "a").Return(&catalog.CredentialInfo{
Name: "a",
AwsIamRole: &catalog.AwsIamRole{
RoleArn: "def",
ExternalId: "123",
},
Purpose: "SERVICE",
MetastoreId: "d",
Id: "1234-5678",
Owner: "f",
IsolationMode: "ISOLATION_MODE_ISOLATED",
}, nil)
},
Resource: ResourceCredential(),
Create: true,
HCL: `
name = "a"
aws_iam_role {
role_arn = "def"
}
comment = "c"
purpose = "SERVICE"
isolation_mode = "ISOLATION_MODE_ISOLATED"
`,
}.ApplyAndExpectData(t, map[string]any{
"aws_iam_role.0.external_id": "123",
"aws_iam_role.0.role_arn": "def",
"name": "a",
"isolation_mode": "ISOLATION_MODE_ISOLATED",
})
}
2 changes: 1 addition & 1 deletion catalog/resource_grant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ func TestResourceGrantCreateNoSecurable(t *testing.T) {
principal = "me"
privileges = ["MODIFY", "SELECT"]
`,
}.ExpectError(t, "invalid config supplied. [catalog] Missing required argument. [external_location] Missing required argument. [foreign_connection] Missing required argument. [function] Missing required argument. [metastore] Missing required argument. [model] Missing required argument. [pipeline] Missing required argument. [recipient] Missing required argument. [schema] Missing required argument. [share] Missing required argument. [storage_credential] Missing required argument. [table] Missing required argument. [volume] Missing required argument")
}.ExpectError(t, "invalid config supplied. [catalog] Missing required argument. [credential] Missing required argument. [external_location] Missing required argument. [foreign_connection] Missing required argument. [function] Missing required argument. [metastore] Missing required argument. [model] Missing required argument. [pipeline] Missing required argument. [recipient] Missing required argument. [schema] Missing required argument. [share] Missing required argument. [storage_credential] Missing required argument. [table] Missing required argument. [volume] Missing required argument")
}

func TestResourceGrantCreateOneSecurableOnly(t *testing.T) {
Expand Down
Loading

0 comments on commit f119680

Please sign in to comment.