Skip to content

Commit

Permalink
feat: Service Accounts Datasource + Resource (#49)
Browse files Browse the repository at this point in the history
* Add service account API calls

* Add SA structs for request payloads

* Fix ServiceAccount client

* Fix return type for SA client

* Add json fields to structs for unmarshalling

* Add ServiceAccounts to PrefectClient interface

* Fix API calls to use the SA client struct properly

* Move getServiceAccountScopedURL to util file and restructure to align with other url functions

* Use getAccountScopedURL to generate the SA route prefix

* Fix route prefix for SA

* Add skeleton'ish service account tf resource

* Add more fields of SA resource structure

* SA resource

* New ServiceAccount struct

* Remove redundant structs

* Update ServiceAccountClient return types

* List client function for service accounts

* Spacing

* Align struct names with other resources

* Test

* test

* Import google uuid for SA

* Add temporary replace directive to go.mod for development

* Separate structs to handle service account List responses

* Add Request suffix to all SA request structs. Remove response marshaling for SA Update

* Refactor client.Create to use NewRequestWithContext

* Refactor Get & Update client calls to use NewRequestWithContext

* Refactor Delete client call

* Fix struct naming for ServiceAccountNoKey

* Begin implementing service_account resource code

* Struct changes

* todo note

* Implement function to copy ServiceAccount data to a ServiceAccountResourceModel

* Create tf operation for service accounts - base structure

* Implement tfsdk Read operation for service account resource

* Implement tfsdk Update operation for service accounts

* Implement Delete and Import tfsdk operations for Service Accounts

* Fix package in SA resource code

* Add base structure for service account datasource implementation - incomplete

* Adds SA attributres for SA datasource

* Assign the sa attributes to the model in the datasource

* Add placeholder install target

* Remove unused imports. Fix ServiceAccountsClient errors

* Remove unused imports from sa datasource. Fix references to ServiceAccountSourceModel

* Increase test fail limit. Remove unused resources from sa resource

* Fix references c->sa

* Fix sa.Name string() call

* Comment out diags reference for testing

* Change AccountId to uuid custom type in the response block

* Cast AccountRoleId to string literal

* Ensure the sa client unmarshals directly to ServiceAccount object, no longer requiring ServiceAccountNoKey

* Linter fixes + added example SA definition in examples/

* Linter fixes

* Add SA resource and data source items to the provider

* Set vars for accountid and api key, and a dummy tfvars file

* Update ServiceAccount client contructor to set accountID to default (global) if not specified in the resource

* Update SA client create function to embed the response body into the error message.

* Handle optional fields in the create payload

* Update comment for setting APIKeyExpiration

* Add better http status code handling. Fix bug in client Get method

* linting

* add service accoutn datasource acc tests

* minor fixes arising from tests

* ignore lint

---------

Co-authored-by: dev-adeebr <[email protected]>
Co-authored-by: Edward Park <[email protected]>
  • Loading branch information
3 people authored Oct 24, 2023
1 parent 40eb98b commit be52e21
Show file tree
Hide file tree
Showing 19 changed files with 937 additions and 56 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
terraform.tfstate
terraform.tfstate.*.backup
terraform.tfstate.backup
local.tfvars

# Built artifacts
build/*
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ lint:
golangci-lint run
.PHONY: lint

install: clean build
echo "@TODO Placeholder install - move built provider to ~.terraform.d/plugins/"

test:
gotestsum --max-fails=10 ./...
gotestsum --max-fails=50 ./...
.PHONY: test
10 changes: 5 additions & 5 deletions examples/data_sources.tf
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
resource "prefect_work_pool" "abc" {
name = "testpool"
type = "kubernetes"
paused = false
}
#resource "prefect_work_pool" "abc" {
# name = "testpool"
# type = "kubernetes"
# paused = false
#}
3 changes: 3 additions & 0 deletions examples/local.tfvars
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
PREFECT_API_KEY=""
PREFECT_ACCOUNT_ID=""
PREFECT_API_URL="https://api.prefect.cloud/api"
2 changes: 2 additions & 0 deletions examples/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ terraform {
}

provider "prefect" {
api_key = var.PREFECT_API_KEY
account_id = var.PREFECT_ACCOUNT_ID
}
3 changes: 3 additions & 0 deletions examples/service_accounts.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
resource "prefect_service_account" "sa_example" {
name = "beautiful-service-account-example"
}
14 changes: 14 additions & 0 deletions examples/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
variable "PREFECT_API_KEY" {
description = "The Prefect API key"
type = string
}

variable "PREFECT_ACCOUNT_ID" {
description = "The Prefect Account ID"
type = string
}

variable "PREFECT_API_URL" {
description = "The Prefect API url"
type = string
}
10 changes: 5 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,12 @@ require (
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/zclconf/go-cty v1.13.3 // indirect
golang.org/x/crypto v0.12.0 // indirect
golang.org/x/crypto v0.13.0 // indirect
golang.org/x/exp v0.0.0-20230809150735-7b3493d9a819 // indirect
golang.org/x/mod v0.11.0 // indirect
golang.org/x/net v0.11.0 // indirect
golang.org/x/sys v0.11.0 // indirect
golang.org/x/text v0.12.0 // indirect
golang.org/x/mod v0.12.0 // indirect
golang.org/x/net v0.15.0 // indirect
golang.org/x/sys v0.12.0 // indirect
golang.org/x/text v0.13.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
google.golang.org/grpc v1.56.1 // indirect
Expand Down
20 changes: 10 additions & 10 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -127,17 +127,17 @@ github.com/zclconf/go-cty v1.13.3 h1:m+b9q3YDbg6Bec5rr+KGy1MzEVzY/jC2X+YX4yqKtHI
github.com/zclconf/go-cty v1.13.3/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/exp v0.0.0-20230809150735-7b3493d9a819 h1:EDuYyU/MkFXllv9QF9819VlI9a4tzGuCbhG0ExK9o1U=
golang.org/x/exp v0.0.0-20230809150735-7b3493d9a819/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU=
golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
Expand All @@ -148,14 +148,14 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
Expand Down
1 change: 1 addition & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ type PrefectClient interface {
WorkspaceRoles(accountID uuid.UUID) (WorkspaceRolesClient, error)
WorkPools(accountID uuid.UUID, workspaceID uuid.UUID) (WorkPoolsClient, error)
Variables(accountID uuid.UUID, workspaceID uuid.UUID) (VariablesClient, error)
ServiceAccounts(accountID uuid.UUID) (ServiceAccountsClient, error)
}
69 changes: 69 additions & 0 deletions internal/api/service_accounts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package api

import (
"context"
"time"

"github.com/google/uuid"
)

type ServiceAccountsClient interface {
Create(ctx context.Context, request ServiceAccountCreateRequest) (*ServiceAccount, error)
List(ctx context.Context, filter ServiceAccountFilterRequest) ([]*ServiceAccount, error)
Get(ctx context.Context, name string) (*ServiceAccount, error)
Update(ctx context.Context, name string, data ServiceAccountUpdateRequest) error
Delete(ctx context.Context, name string) error
}

/*** REQUEST DATA STRUCTS ***/

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

type ServiceAccountUpdateRequest struct {
Name string `json:"name"`
}

type ServiceAccountFilterRequest struct {
Any []uuid.UUID `json:"any_"`
}

/*** RESPONSE DATA STRUCTS ***/

// ServiceAccount is a representation of a created service account (from a Create response).
type ServiceAccount struct {
BaseModel
AccountID uuid.UUID `json:"account_id"`
Name string `json:"name"`
AccountRoleName string `json:"account_role_name"`
APIKey ServiceAccountAPIKey `json:"api_key"`
}

type ServiceAccountAPIKey struct {
ID string `json:"id"`
Created *time.Time `json:"created"`
Name string `json:"name"`
Expiration *time.Time `json:"expiration"`
Key string `json:"key"`
}

// ServiceAccountNoKey is a representation of Service Account details obtained from a List/Filter
// and excludes the actual key value for the api_key.
type ServiceAccountNoKey struct {
BaseModel
AccountID uuid.UUID `json:"account_id"`
Name string `json:"name"`
AccountRoleName string `json:"account_role_name"`
APIKey ServiceAccountAPIKeyNoKey `json:"api_key"`
}

// Represents an api_key block received from a List/Filter response, which excludes the actual key.
type ServiceAccountAPIKeyNoKey struct {
ID string `json:"id"`
Created *time.Time `json:"created"`
Name string `json:"name"`
Expiration *time.Time `json:"expiration"`
}
2 changes: 1 addition & 1 deletion internal/client/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func (c *Client) Accounts() (api.AccountsClient, error) {
return &AccountsClient{
hc: c.hc,
apiKey: c.apiKey,
routePrefix: fmt.Sprintf("%s/api/accounts", c.endpoint),
routePrefix: fmt.Sprintf("%s/accounts", c.endpoint),
}, nil
}

Expand Down
188 changes: 188 additions & 0 deletions internal/client/service_accounts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package client

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

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

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

//nolint:ireturn // required to support PrefectClient mocking
func (c *Client) ServiceAccounts(accountID uuid.UUID) (api.ServiceAccountsClient, error) {
if c.apiKey == "" {
return nil, fmt.Errorf("apiKey is not set")
}

if c.endpoint == "" {
return nil, fmt.Errorf("endpoint is not set")
}

if accountID == uuid.Nil {
accountID = c.defaultAccountID
}

// Since service accounts are account scoped. Generate from util.getAccountScopedURL
// e.g. this will generate routePrefix ending in /accounts/bots
routePrefix := getAccountScopedURL(c.endpoint, accountID, "bots")

return &ServiceAccountsClient{
hc: c.hc,
apiKey: c.apiKey,
routePrefix: routePrefix,
}, nil
}

func (sa *ServiceAccountsClient) Create(ctx context.Context, request api.ServiceAccountCreateRequest) (*api.ServiceAccount, error) {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(&request); err != nil {
return nil, fmt.Errorf("failed to encode request data: %w", err)
}

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

setDefaultHeaders(req, sa.apiKey)

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

if resp.StatusCode != http.StatusCreated {
// Read the response body
bodyBytes, _ := io.ReadAll(resp.Body)
bodyString := string(bodyBytes)

return nil, fmt.Errorf("status code: %s, response body: %s", resp.Status, bodyString)
}

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

return &response, nil
}

func (sa *ServiceAccountsClient) List(ctx context.Context, filter api.ServiceAccountFilterRequest) ([]*api.ServiceAccount, error) {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(&filter); err != nil {
return nil, fmt.Errorf("failed to encode filter: %w", err)
}

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

setDefaultHeaders(req, sa.apiKey)

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

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status code %s", resp.Status)
}

var serviceAccounts []*api.ServiceAccount
if err := json.NewDecoder(resp.Body).Decode(&serviceAccounts); err != nil { // THIS IS THE RESPONSE
return nil, fmt.Errorf("failed to decode response: %w", err)
}

return serviceAccounts, nil
}

func (sa *ServiceAccountsClient) Get(ctx context.Context, botID string) (*api.ServiceAccount, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sa.routePrefix+"/"+botID, http.NoBody)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}

setDefaultHeaders(req, sa.apiKey)

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

switch resp.StatusCode {
case http.StatusOK:
case http.StatusNotFound:
return nil, fmt.Errorf("could not find Service Account")
default:
bodyBytes, _ := io.ReadAll(resp.Body)

return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(bodyBytes))
}

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

return &response, nil
}

func (sa *ServiceAccountsClient) Update(ctx context.Context, botID string, request api.ServiceAccountUpdateRequest) error {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(&request); err != nil {
return fmt.Errorf("failed to encode request data: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodPatch, sa.routePrefix+"/"+botID, &buf)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}

setDefaultHeaders(req, sa.apiKey)

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

if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("status code %s", resp.Status)
}

return nil
}

func (sa *ServiceAccountsClient) Delete(ctx context.Context, botID string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, sa.routePrefix+"/"+botID, http.NoBody)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
setDefaultHeaders(req, sa.apiKey)

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

if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("status code %s", resp.Status)
}

return nil
}
Loading

0 comments on commit be52e21

Please sign in to comment.