Skip to content

Commit

Permalink
feat: prefect_account_role Datasource (#77)
Browse files Browse the repository at this point in the history
* add account_roles client

* test pre-commit

* test pre-commit

* test pre-commit

* test pre-commit

* test pre-commit

* add account_roles client

* add account_role datasource

* remaining

* add terraform_fmt

* some terraform file formatting

* add name validator

* add account role lookup

* fix tests
  • Loading branch information
parkedwards authored Oct 27, 2023
1 parent 2f6d27f commit 5199e17
Show file tree
Hide file tree
Showing 16 changed files with 489 additions and 91 deletions.
17 changes: 17 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
fail_fast: false

repos:
- repo: local
hooks:
- id: golangci-lint
name: golangci-lint
entry: golangci-lint run
types: [go]
language: golang
require_serial: true
pass_filenames: false
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.83.5
hooks:
- id: terraform_fmt
7 changes: 7 additions & 0 deletions examples/account_roles.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
data "prefect_account_role" "owner" {
name = "Owner"
}

data "prefect_account_role" "member" {
name = "Member"
}
6 changes: 3 additions & 3 deletions examples/local.tfvars
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
PREFECT_API_KEY=""
PREFECT_ACCOUNT_ID=""
PREFECT_API_URL="https://api.prefect.cloud/api"
PREFECT_API_KEY = ""
PREFECT_ACCOUNT_ID = ""
PREFECT_API_URL = "https://api.prefect.cloud/api"
2 changes: 1 addition & 1 deletion examples/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ terraform {
}

provider "prefect" {
api_key = var.PREFECT_API_KEY
api_key = var.PREFECT_API_KEY
account_id = var.PREFECT_ACCOUNT_ID
}
6 changes: 3 additions & 3 deletions examples/service_accounts.tf
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
provider "time" {}
resource "time_rotating" "ninety_days" {
rotation_days = 90
rotation_days = 90
}

resource "prefect_service_account" "sa_example" {
name = "my-service-account"
api_key_expiration = time_rotating.ninety_days.rotation_rfc3339
name = "my-service-account"
api_key_expiration = time_rotating.ninety_days.rotation_rfc3339
}
34 changes: 34 additions & 0 deletions internal/api/account_roles.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package api

import (
"context"

"github.com/google/uuid"
)

type AccountRolesClient interface {
Get(ctx context.Context, roleID uuid.UUID) (*AccountRole, error)
List(ctx context.Context, roleNames []string) ([]*AccountRole, error)
}

// AccountRole is a representation of an account role.
type AccountRole struct {
BaseModel
Name string `json:"name"`
Permissions []string `json:"permissions"`

AccountID *uuid.UUID `json:"account_id"`
IsSystemRole bool `json:"is_system_role"`
}

// AccountRoleFilter defines the search filter payload
// when searching for workspace roles by name.
// example request payload:
// {"account_roles": {"name": {"any_": ["test"]}}}.
type AccountRoleFilter struct {
AccountRoles struct {
Name struct {
Any []string `json:"any_"`
} `json:"name"`
} `json:"account_roles"`
}
1 change: 1 addition & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import "github.com/google/uuid"
// PrefectClient returns clients for different aspects of our API.
type PrefectClient interface {
Accounts() (AccountsClient, error)
AccountRoles(accountID uuid.UUID) (AccountRolesClient, error)
Workspaces(accountID uuid.UUID) (WorkspacesClient, error)
WorkspaceAccess(accountID uuid.UUID, workspaceID uuid.UUID) (WorkspaceAccessClient, error)
WorkspaceRoles(accountID uuid.UUID) (WorkspaceRolesClient, error)
Expand Down
6 changes: 3 additions & 3 deletions internal/api/service_accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ type ServiceAccountsClient interface {
/*** REQUEST DATA STRUCTS ***/

type ServiceAccountCreateRequest struct {
Name string `json:"name"`
APIKeyExpiration string `json:"api_key_expiration,omitempty"`
AccountRoleID string `json:"account_role_id,omitempty"`
Name string `json:"name"`
APIKeyExpiration string `json:"api_key_expiration,omitempty"`
AccountRoleID *uuid.UUID `json:"account_role_id,omitempty"`
}

type ServiceAccountUpdateRequest struct {
Expand Down
102 changes: 102 additions & 0 deletions internal/client/account_roles.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package client

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"

"github.com/google/uuid"
"github.com/prefecthq/terraform-provider-prefect/internal/api"
)

// type assertion ensures that this client implements the interface.
var _ = api.AccountRolesClient(&AccountRolesClient{})

type AccountRolesClient struct {
hc *http.Client
apiKey string
routePrefix string
}

// AccountRoles is a factory that initializes and returns a AccountRolesClient.
//
//nolint:ireturn // required to support PrefectClient mocking
func (c *Client) AccountRoles(accountID uuid.UUID) (api.AccountRolesClient, error) {
if accountID == uuid.Nil {
accountID = c.defaultAccountID
}

return &AccountRolesClient{
hc: c.hc,
apiKey: c.apiKey,
routePrefix: fmt.Sprintf("%s/accounts/%s/account_roles", c.endpoint, accountID.String()),
}, nil
}

// List returns a list of account roles, based on the provided filter.
func (c *AccountRolesClient) List(ctx context.Context, roleNames []string) ([]*api.AccountRole, error) {
var buf bytes.Buffer
filterQuery := api.AccountRoleFilter{}
filterQuery.AccountRoles.Name.Any = roleNames

if err := json.NewEncoder(&buf).Encode(&filterQuery); err != nil {
return nil, fmt.Errorf("failed to encode filter payload data: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("%s/filter", c.routePrefix), &buf)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}

setDefaultHeaders(req, c.apiKey)

resp, err := c.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("http error: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
errorBody, _ := io.ReadAll(resp.Body)

return nil, fmt.Errorf("status code %s, error=%s", resp.Status, errorBody)
}

var accountRoles []*api.AccountRole
if err := json.NewDecoder(resp.Body).Decode(&accountRoles); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}

return accountRoles, nil
}

// Get returns an account role by ID.
func (c *AccountRolesClient) Get(ctx context.Context, roleID uuid.UUID) (*api.AccountRole, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/%s", c.routePrefix, roleID.String()), http.NoBody)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
setDefaultHeaders(req, c.apiKey)

resp, err := c.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("http error: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
errorBody, _ := io.ReadAll(resp.Body)

return nil, fmt.Errorf("status code %s, error=%s", resp.Status, errorBody)
}

var accountRole api.AccountRole
if err := json.NewDecoder(resp.Body).Decode(&accountRole); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}

return &accountRole, nil
}
172 changes: 172 additions & 0 deletions internal/provider/datasources/account_role.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package datasources

import (
"context"
"fmt"

"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/prefecthq/terraform-provider-prefect/internal/api"
"github.com/prefecthq/terraform-provider-prefect/internal/provider/customtypes"
"github.com/prefecthq/terraform-provider-prefect/internal/provider/helpers"
)

// Ensure the implementation satisfies the expected interfaces.
var _ datasource.DataSource = &AccountRoleDataSource{}
var _ datasource.DataSourceWithConfigure = &AccountRoleDataSource{}

type AccountRoleDataSource struct {
client api.PrefectClient
}

// AccountRoleDataSource defines the Terraform data source model
// the TF data source configuration will be unmarshalled into this struct.
type AccountRoleDataSourceModel struct {
ID customtypes.UUIDValue `tfsdk:"id"`
Created customtypes.TimestampValue `tfsdk:"created"`
Updated customtypes.TimestampValue `tfsdk:"updated"`

Name types.String `tfsdk:"name"`
Permissions types.List `tfsdk:"permissions"`
AccountID customtypes.UUIDValue `tfsdk:"account_id"`
IsSystemRole types.Bool `tfsdk:"is_system_role"`
}

// NewWorkspaceRoleDataSource returns a new WorkspaceRoleDataSource.
//
//nolint:ireturn // required by Terraform API
func NewAccountRoleDataSource() datasource.DataSource {
return &AccountRoleDataSource{}
}

// Metadata returns the data source type name.
func (d *AccountRoleDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_account_role"
}

// Schema defines the schema for the data source.
func (d *AccountRoleDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Data Source representing a Prefect Workspace Role",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
CustomType: customtypes.UUIDType{},
Description: "Account Role UUID",
},
"created": schema.StringAttribute{
Computed: true,
CustomType: customtypes.TimestampType{},
Description: "Date and time of the Account Role creation in RFC 3339 format",
},
"updated": schema.StringAttribute{
Computed: true,
CustomType: customtypes.TimestampType{},
Description: "Date and time that the Account Role was last updated in RFC 3339 format",
},
"name": schema.StringAttribute{
Required: true,
Description: "Name of the Account Role",
Validators: []validator.String{
stringvalidator.OneOf("Admin", "Member"),
},
},
"permissions": schema.ListAttribute{
Computed: true,
Description: "List of permissions linked to the Account Role",
ElementType: types.StringType,
},
"account_id": schema.StringAttribute{
Optional: true,
CustomType: customtypes.UUIDType{},
Description: "Account UUID where Account Role resides",
},
"is_system_role": schema.BoolAttribute{
Computed: true,
Description: "Account UUID where Account Role resides",
},
},
}
}

// Configure adds the provider-configured client to the data source.
func (d *AccountRoleDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
if req.ProviderData == nil {
return
}

client, ok := req.ProviderData.(api.PrefectClient)
if !ok {
resp.Diagnostics.AddError(
"Unexpected Data Source Configure Type",
fmt.Sprintf("Expected api.PrefectClient, got: %T. Please report this issue to the provider developers.", req.ProviderData),
)

return
}

d.client = client
}

// Read refreshes the Terraform state with the latest data.
func (d *AccountRoleDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
var config AccountRoleDataSourceModel

// Populate the model from data source configuration and emit diagnostics on error
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}

client, err := d.client.AccountRoles(config.AccountID.ValueUUID())
if err != nil {
resp.Diagnostics.Append(helpers.CreateClientErrorDiagnostic("Account Roles", err))

return
}

// Fetch an existing Account Role by name
// Here, we'd expect only 1 Role (or none) to be returned
// as we are querying a single Role name, not a list of names
accountRoles, err := client.List(ctx, []string{config.Name.ValueString()})
if err != nil {
resp.Diagnostics.AddError(
"Error refreshing Workspace Role state",
fmt.Sprintf("Could not read Workspace Role, unexpected error: %s", err.Error()),
)
}

if len(accountRoles) != 1 {
resp.Diagnostics.AddError(
"Could not find Workspace Role",
fmt.Sprintf("Could not find Workspace Role with name %s", config.Name.String()),
)

return
}

fetchedRole := accountRoles[0]

config.ID = customtypes.NewUUIDValue(fetchedRole.ID)
config.Created = customtypes.NewTimestampPointerValue(fetchedRole.Created)
config.Updated = customtypes.NewTimestampPointerValue(fetchedRole.Updated)

config.Name = types.StringValue(fetchedRole.Name)
config.AccountID = customtypes.NewUUIDPointerValue(fetchedRole.AccountID)
config.IsSystemRole = types.BoolValue(fetchedRole.IsSystemRole)

list, diags := types.ListValueFrom(ctx, types.StringType, fetchedRole.Permissions)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
config.Permissions = list

resp.Diagnostics.Append(resp.State.Set(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}
}
Loading

0 comments on commit 5199e17

Please sign in to comment.