Skip to content

Commit

Permalink
feat: prefect_service_account can be queried and imported by name, …
Browse files Browse the repository at this point in the history
…as well as ID (#107)

* setup correct service account filter query

* feat: allow prefect_service_account query and import via name

* docs

* Generate Terraform Docs

* fix test

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
  • Loading branch information
parkedwards and github-actions[bot] authored Nov 4, 2023
1 parent 8e00901 commit 86a955c
Show file tree
Hide file tree
Showing 10 changed files with 155 additions and 29 deletions.
17 changes: 10 additions & 7 deletions docs/data-sources/service_account.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,25 @@ Data Source representing a Prefect Service Account
## Example Usage

```terraform
data "prefect_service_account" "my_existing_bot" {
id = "service-acount-UUID"
data "prefect_service_account" "bot" {
id = "7de0291d-59d0-4d57-a629-fe47960aa61b"
}
# or
data "prefect_service_account" "bot" {
name = "my-bot-name"
}
```

<!-- schema generated by tfplugindocs -->
## Schema

### Required

- `id` (String) Service Account UUID

### Optional

- `account_id` (String) Account UUID, defaults to the account set in the provider
- `id` (String) Service Account UUID
- `name` (String) Name of the service account

### Read-Only

Expand All @@ -38,5 +42,4 @@ data "prefect_service_account" "my_existing_bot" {
- `api_key_id` (String) API Key ID associated with the service account
- `api_key_name` (String) API Key Name associated with the service account
- `created` (String) Date and time of the Service Account creation in RFC 3339 format
- `name` (String) Name of the service account
- `updated` (String) Date and time that the Service Account was last updated in RFC 3339 format
7 changes: 5 additions & 2 deletions docs/resources/service_account.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ resource "prefect_service_account" "example" {
Import is supported using the following syntax:

```shell
# Prefect Service Accounts can be imported using the service account's name
terraform import prefect_service_account.example name-of-service-account
# Prefect Service Accounts can be imported via name in the form `name/my-bot-name`
terraform import prefect_service_account.example name/my-bot-name

# Prefect Service Accounts can also be imported via UUID
terraform import prefect_service_account.example 7de0291d-59d0-4d57-a629-fe47960aa61b
```
10 changes: 8 additions & 2 deletions examples/data-sources/prefect_service_account/data-source.tf
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
data "prefect_service_account" "my_existing_bot" {
id = "service-acount-UUID"
data "prefect_service_account" "bot" {
id = "7de0291d-59d0-4d57-a629-fe47960aa61b"
}

# or

data "prefect_service_account" "bot" {
name = "my-bot-name"
}
7 changes: 5 additions & 2 deletions examples/resources/prefect_service_account/import.sh
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
# Prefect Service Accounts can be imported using the service account's name
terraform import prefect_service_account.example name-of-service-account
# Prefect Service Accounts can be imported via name in the form `name/my-bot-name`
terraform import prefect_service_account.example name/my-bot-name

# Prefect Service Accounts can also be imported via UUID
terraform import prefect_service_account.example 7de0291d-59d0-4d57-a629-fe47960aa61b
14 changes: 11 additions & 3 deletions internal/api/service_accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (

type ServiceAccountsClient interface {
Create(ctx context.Context, request ServiceAccountCreateRequest) (*ServiceAccount, error)
List(ctx context.Context, filter ServiceAccountFilterRequest) ([]*ServiceAccount, error)
List(ctx context.Context, names []string) ([]*ServiceAccount, error)
Get(ctx context.Context, id string) (*ServiceAccount, error)
Update(ctx context.Context, id string, data ServiceAccountUpdateRequest) error
Delete(ctx context.Context, id string) error
Expand All @@ -33,8 +33,16 @@ type ServiceAccountRotateKeyRequest struct {
APIKeyExpiration *time.Time `json:"api_key_expiration"`
}

type ServiceAccountFilterRequest struct {
Any []uuid.UUID `json:"any_"`
// ServiceAccountFilter defines the search filter payload
// when searching for service accounts by name.
// example request payload:
// {"service_accounts": {"name": {"any_": ["test"]}}}.
type ServiceAccountFilter struct {
ServiceAccounts struct {
Name struct {
Any []string `json:"any_"`
} `json:"name,omitempty"`
} `json:"service_accounts"`
}

/*** RESPONSE DATA STRUCTS ***/
Expand Down
5 changes: 4 additions & 1 deletion internal/client/service_accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ func (sa *ServiceAccountsClient) Create(ctx context.Context, request api.Service
return &response, nil
}

func (sa *ServiceAccountsClient) List(ctx context.Context, filter api.ServiceAccountFilterRequest) ([]*api.ServiceAccount, error) {
func (sa *ServiceAccountsClient) List(ctx context.Context, names []string) ([]*api.ServiceAccount, error) {
filter := api.ServiceAccountFilter{}
filter.ServiceAccounts.Name.Any = names

var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(&filter); err != nil {
return nil, fmt.Errorf("failed to encode filter: %w", err)
Expand Down
43 changes: 41 additions & 2 deletions internal/provider/datasources/service_account.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ func (d *ServiceAccountDataSource) Configure(_ context.Context, req datasource.C

var serviceAccountAttributes = map[string]schema.Attribute{
"id": schema.StringAttribute{
Required: true,
Computed: true,
Optional: true,
CustomType: customtypes.UUIDType{},
Description: "Service Account UUID",
},
Expand All @@ -91,6 +92,7 @@ var serviceAccountAttributes = map[string]schema.Attribute{
},
"name": schema.StringAttribute{
Computed: true,
Optional: true,
Description: "Name of the service account",
},
"account_role_name": schema.StringAttribute{
Expand Down Expand Up @@ -139,6 +141,15 @@ func (d *ServiceAccountDataSource) Read(ctx context.Context, req datasource.Read
return
}

if model.ID.IsNull() && model.Name.IsNull() {
resp.Diagnostics.AddError(
"Both ID and Name are unset",
"Either a Service Account ID or Name are required to read a Service Account.",
)

return
}

client, err := d.client.ServiceAccounts(model.AccountID.ValueUUID())
if err != nil {
resp.Diagnostics.AddError(
Expand All @@ -149,7 +160,35 @@ func (d *ServiceAccountDataSource) Read(ctx context.Context, req datasource.Read
return
}

serviceAccount, err := client.Get(ctx, model.ID.ValueString())
// A Service Account can be read by either ID or Name.
// If both are set, we prefer the ID
var serviceAccount *api.ServiceAccount
if !model.ID.IsNull() {
serviceAccount, err = client.Get(ctx, model.ID.ValueString())
} else if !model.Name.IsNull() {
var serviceAccounts []*api.ServiceAccount
serviceAccounts, err = client.List(ctx, []string{model.Name.ValueString()})

// The error from the API call should take precedence
// followed by this custom error if a specific service account is not returned
if err == nil && len(serviceAccounts) != 1 {
err = fmt.Errorf("a Service Account with the name=%s could not be found", model.Name.ValueString())
}

if len(serviceAccounts) == 1 {
serviceAccount = serviceAccounts[0]
}
}

if serviceAccount == nil {
resp.Diagnostics.AddError(
"Error refreshing Service Account state",
fmt.Sprintf("Could not find Service Account with ID=%s and Name=%s", model.ID.ValueString(), model.Name.ValueString()),
)

return
}

if err != nil {
resp.Diagnostics.AddError(
"Error refreshing Service Account state",
Expand Down
23 changes: 16 additions & 7 deletions internal/provider/datasources/service_account_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import (

//nolint:paralleltest // we use the resource.ParallelTest helper instead
func TestAccDatasource_service_account(t *testing.T) {
dataSourceName := "data.prefect_service_account.bot"
dataSourceNameByID := "data.prefect_service_account.bot_by_id"
dataSourceNameByName := "data.prefect_service_account.bot_by_name"
// generate random resource name
randomName := testutils.TestAccPrefix + acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum)

Expand All @@ -23,11 +24,16 @@ func TestAccDatasource_service_account(t *testing.T) {
{
Config: fixtureAccServiceAccountDataSource(randomName),
Check: resource.ComposeAggregateTestCheckFunc(
// Check the prefect_service_account datasource
resource.TestCheckResourceAttr(dataSourceName, "name", randomName),
resource.TestMatchResourceAttr(dataSourceName, "api_key_name", regexp.MustCompile((fmt.Sprintf(`^%s`, randomName)))),
resource.TestCheckResourceAttrSet(dataSourceName, "created"),
resource.TestCheckResourceAttrSet(dataSourceName, "updated"),
// Check the prefect_service_account datasource that queries by ID
resource.TestCheckResourceAttr(dataSourceNameByID, "name", randomName),
resource.TestMatchResourceAttr(dataSourceNameByID, "api_key_name", regexp.MustCompile((fmt.Sprintf(`^%s`, randomName)))),
resource.TestCheckResourceAttrSet(dataSourceNameByID, "created"),
resource.TestCheckResourceAttrSet(dataSourceNameByID, "updated"),
// Check the prefect_service_account datasource that queries by name
resource.TestCheckResourceAttr(dataSourceNameByName, "name", randomName),
resource.TestMatchResourceAttr(dataSourceNameByName, "api_key_name", regexp.MustCompile((fmt.Sprintf(`^%s`, randomName)))),
resource.TestCheckResourceAttrSet(dataSourceNameByName, "created"),
resource.TestCheckResourceAttrSet(dataSourceNameByName, "updated"),
),
},
},
Expand All @@ -39,8 +45,11 @@ func fixtureAccServiceAccountDataSource(name string) string {
resource "prefect_service_account" "bot" {
name = "%s"
}
data "prefect_service_account" "bot" {
data "prefect_service_account" "bot_by_id" {
id = prefect_service_account.bot.id
}
data "prefect_service_account" "bot_by_name" {
name = prefect_service_account.bot.name
}
`, name)
}
9 changes: 9 additions & 0 deletions internal/provider/datasources/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,15 @@ func (d *WorkspaceDataSource) Read(ctx context.Context, req datasource.ReadReque
}
}

if workspace == nil {
resp.Diagnostics.AddError(
"Error refreshing workspace state",
fmt.Sprintf("Could not find workspace with ID=%s and Handle=%s", model.ID.ValueString(), model.Handle.ValueString()),
)

return
}

if err != nil {
resp.Diagnostics.AddError(
"Error refreshing workspace state",
Expand Down
49 changes: 46 additions & 3 deletions internal/provider/resources/service_account.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package resources
import (
"context"
"fmt"
"strings"
"time"

"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
Expand Down Expand Up @@ -258,18 +259,55 @@ func (r *ServiceAccountResource) Read(ctx context.Context, req resource.ReadRequ
return
}

if model.ID.IsNull() && model.Name.IsNull() {
resp.Diagnostics.AddError(
"Both ID and Name are unset",
"This is a bug in the Terraform provider. Please report it to the maintainers.",
)

return
}

client, err := r.client.ServiceAccounts(model.AccountID.ValueUUID())
if err != nil {
resp.Diagnostics.Append(helpers.CreateClientErrorDiagnostic("Service Account", err))

return
}

serviceAccount, err := client.Get(ctx, model.ID.ValueString())
// A Service Account can be read by either ID or Name.
// If both are set, we prefer the ID
var serviceAccount *api.ServiceAccount
if !model.ID.IsNull() {
serviceAccount, err = client.Get(ctx, model.ID.ValueString())
} else if !model.Name.IsNull() {
var serviceAccounts []*api.ServiceAccount
serviceAccounts, err = client.List(ctx, []string{model.Name.ValueString()})

// The error from the API call should take precedence
// followed by this custom error if a specific service account is not returned
if err == nil && len(serviceAccounts) != 1 {
err = fmt.Errorf("a Service Account with the name=%s could not be found", model.Name.ValueString())
}

if len(serviceAccounts) == 1 {
serviceAccount = serviceAccounts[0]
}
}

if serviceAccount == nil {
resp.Diagnostics.AddError(
"Error refreshing Service Account state",
fmt.Sprintf("Could not find Service Account with ID=%s and Name=%s", model.ID.ValueString(), model.Name.ValueString()),
)

return
}

if err != nil {
resp.Diagnostics.AddError(
"Error refreshing Service Account state",
fmt.Sprintf("Could not read Service Account, unexpected error: %s", err),
fmt.Sprintf("Could not read Service Account, unexpected error: %s", err.Error()),
)

return
Expand Down Expand Up @@ -444,5 +482,10 @@ func (r *ServiceAccountResource) Delete(ctx context.Context, req resource.Delete

// ImportState imports the resource into Terraform state.
func (r *ServiceAccountResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp)
if strings.HasPrefix(req.ID, "name/") {
name := strings.TrimPrefix(req.ID, "name/")
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), name)...)
} else {
resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp)
}
}

0 comments on commit 86a955c

Please sign in to comment.