Skip to content

Commit

Permalink
feat(accounts): support account domains (#382)
Browse files Browse the repository at this point in the history
* Merge AccountResponse into Account

This better follows the pattern of the other APIs, and this was an
unnecessary extra struct.

* Remove inactive fields

These fields are not currently implemented, and most users probably
won't want or need to reference them. They're also read-only.

* Separate read-only fields

* Deduplicate AccountUpdate fields

* Implement auth_expiration_seconds attribute

* Resource: implement account domains

* Datasource: implement domain names

* Revert "Implement auth_expiration_seconds attribute"

This reverts commit 54fd6d0.

No need to expose this field.

* Set basic auth key on account client requests
  • Loading branch information
mitchnielsen authored Feb 20, 2025
1 parent 60533cb commit 0e8c122
Show file tree
Hide file tree
Showing 7 changed files with 134 additions and 33 deletions.
1 change: 1 addition & 0 deletions docs/data-sources/account.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ data "prefect_account" "my_organization" {}

- `billing_email` (String) Billing email to apply to the account's Stripe customer
- `created` (String) Timestamp of when the resource was created (RFC3339)
- `domain_names` (List of String) The list of domain names for enabling SSO in Prefect Cloud.
- `handle` (String) Unique handle of the account
- `link` (String) An optional for an external url associated with the account, e.g. https://prefect.io/
- `location` (String) An optional physical location for the account, e.g. Washington, D.C.
Expand Down
1 change: 1 addition & 0 deletions docs/resources/account.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ resource "prefect_account" "example" {
### Optional

- `billing_email` (String) Billing email to apply to the account's Stripe customer
- `domain_names` (List of String) The list of domain names for enabling SSO in Prefect Cloud.
- `link` (String) An optional for an external url associated with the account, e.g. https://prefect.io/
- `location` (String) An optional physical location for the account, e.g. Washington, D.C.
- `settings` (Attributes) Group of settings related to accounts (see [below for nested schema](#nestedatt--settings))
Expand Down
41 changes: 18 additions & 23 deletions internal/api/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import (

// AccountsClient is a client for working with accounts.
type AccountsClient interface {
Get(ctx context.Context) (*AccountResponse, error)
Get(ctx context.Context) (*Account, error)
GetDomains(ctx context.Context) (*AccountDomainsUpdate, error)
Update(ctx context.Context, data AccountUpdate) error
UpdateSettings(ctx context.Context, data AccountSettingsUpdate) error
UpdateDomains(ctx context.Context, data AccountDomainsUpdate) error
Delete(ctx context.Context) error
}

Expand All @@ -22,37 +24,25 @@ type AccountSettings struct {
// Account is a representation of an account.
type Account struct {
BaseModel
Name string `json:"name"`
Handle string `json:"handle"`
Location *string `json:"location"`
Link *string `json:"link"`
ImageLocation *string `json:"image_location"`
StripeCustomerID *string `json:"stripe_customer_id"`
WorkOSDirectoryIDs []string `json:"workos_directory_ids"`
WorkOSOrganizationID *string `json:"workos_organization_id"`
WorkOSConnectionIDs []string `json:"workos_connection_ids"`
AuthExpirationSeconds *int64 `json:"auth_expiration_seconds"`
Settings AccountSettings `json:"settings"`
}
AccountUpdate

Settings AccountSettings `json:"settings"`
Domains []string `json:"domain_names"`

// AccountResponse is the data about an account returned by the Accounts API.
type AccountResponse struct {
Account
// Read-only fields
ImageLocation *string `json:"image_location"`
SSOState string `json:"sso_state"`
Features []string `json:"features"`
PlanType string `json:"plan_type"`
SelfServe bool `json:"self_serve"`
RunRetentionDays int64 `json:"run_retention_days"`
AuditLogRetentionDays int64 `json:"audit_log_retention_days"`
AutomationsLimit int64 `json:"automations_limit"`
SCIMState string `json:"scim_state"`
SSOState string `json:"sso_state"`
BillingEmail *string `json:"billing_email"`
Features []string `json:"features"`
}

// AccountUpdate is the data sent when updating an account.
type AccountUpdate struct {
Name *string `json:"name"`
Handle *string `json:"handle"`
Name string `json:"name"`
Handle string `json:"handle"`
Location *string `json:"location"`
Link *string `json:"link"`
AuthExpirationSeconds *int64 `json:"auth_expiration_seconds"`
Expand All @@ -63,3 +53,8 @@ type AccountUpdate struct {
type AccountSettingsUpdate struct {
AccountSettings `json:"settings"`
}

// AccountDomainsUpdate is the data sent when updating an account's domain names.
type AccountDomainsUpdate struct {
DomainNames []string `json:"domain_names,omitempty"`
}
43 changes: 41 additions & 2 deletions internal/client/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func (c *Client) Accounts(accountID uuid.UUID) (api.AccountsClient, error) {
}

// Get returns details for an account by ID.
func (c *AccountsClient) Get(ctx context.Context) (*api.AccountResponse, error) {
func (c *AccountsClient) Get(ctx context.Context) (*api.Account, error) {
cfg := requestConfig{
method: http.MethodGet,
url: c.routePrefix,
Expand All @@ -50,14 +50,33 @@ func (c *AccountsClient) Get(ctx context.Context) (*api.AccountResponse, error)
successCodes: successCodesStatusOK,
}

var account api.AccountResponse
var account api.Account
if err := requestWithDecodeResponse(ctx, c.hc, cfg, &account); err != nil {
return nil, fmt.Errorf("failed to get account: %w", err)
}

return &account, nil
}

// GetDomains returns domain names for an account by ID.
func (c *AccountsClient) GetDomains(ctx context.Context) (*api.AccountDomainsUpdate, error) {
cfg := requestConfig{
method: http.MethodGet,
url: c.routePrefix + "domains",
body: http.NoBody,
apiKey: c.apiKey,
basicAuthKey: c.basicAuthKey,
successCodes: successCodesStatusOK,
}

var accountDomains api.AccountDomainsUpdate
if err := requestWithDecodeResponse(ctx, c.hc, cfg, &accountDomains.DomainNames); err != nil {
return nil, fmt.Errorf("failed to get account domains: %w", err)
}

return &accountDomains, nil
}

// Update modifies an existing account by ID.
func (c *AccountsClient) Update(ctx context.Context, data api.AccountUpdate) error {
cfg := requestConfig{
Expand Down Expand Up @@ -98,6 +117,26 @@ func (c *AccountsClient) UpdateSettings(ctx context.Context, data api.AccountSet
return nil
}

// UpdateDomains modifies an existing account's domain names.
func (c *AccountsClient) UpdateDomains(ctx context.Context, data api.AccountDomainsUpdate) error {
cfg := requestConfig{
method: http.MethodPatch,
url: c.routePrefix + "domains",
body: data.DomainNames,
apiKey: c.apiKey,
basicAuthKey: c.basicAuthKey,
successCodes: successCodesStatusNoContent,
}

resp, err := request(ctx, c.hc, cfg)
if err != nil {
return fmt.Errorf("failed to update account domains: %w", err)
}
defer resp.Body.Close()

return nil
}

// Delete removes an account by ID.
func (c *AccountsClient) Delete(ctx context.Context) error {
cfg := requestConfig{
Expand Down
20 changes: 20 additions & 0 deletions internal/provider/datasources/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type AccountDataSourceModel struct {
Link types.String `tfsdk:"link"`
Settings types.Object `tfsdk:"settings"`
BillingEmail types.String `tfsdk:"billing_email"`
DomainNames types.List `tfsdk:"domain_names"`
}

// NewAccountDataSource returns a new AccountDataSource.
Expand Down Expand Up @@ -126,6 +127,12 @@ Use this data source to obtain account-level attributes
Computed: true,
Description: "Billing email to apply to the account's Stripe customer",
},
"domain_names": schema.ListAttribute{
Description: "The list of domain names for enabling SSO in Prefect Cloud.",
ElementType: types.StringType,
Optional: false,
Computed: true,
},
},
}
}
Expand Down Expand Up @@ -158,6 +165,12 @@ func (d *AccountDataSource) Read(ctx context.Context, req datasource.ReadRequest
return
}

accountDomains, err := client.GetDomains(ctx)
if err != nil {
resp.Diagnostics.Append(helpers.ResourceClientErrorDiagnostic("Account domains", "get", err))

return
}
model.ID = customtypes.NewUUIDValue(account.ID)
model.Created = customtypes.NewTimestampPointerValue(account.Created)
model.Updated = customtypes.NewTimestampPointerValue(account.Updated)
Expand All @@ -181,6 +194,13 @@ func (d *AccountDataSource) Read(ctx context.Context, req datasource.ReadRequest

model.Settings = settingsObject

domainNames, diags := types.ListValueFrom(ctx, types.StringType, accountDomains.DomainNames)
resp.Diagnostics.Append(diags...)
if diags.HasError() {
return
}
model.DomainNames = domainNames

model.BillingEmail = types.StringPointerValue(account.BillingEmail)
model.Handle = types.StringValue(account.Handle)
model.Link = types.StringPointerValue(account.Link)
Expand Down
51 changes: 48 additions & 3 deletions internal/provider/resources/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type AccountResourceModel struct {
Link types.String `tfsdk:"link"`
Settings types.Object `tfsdk:"settings"`
BillingEmail types.String `tfsdk:"billing_email"`
DomainNames types.List `tfsdk:"domain_names"`
}

// NewAccountResource returns a new AccountResource.
Expand Down Expand Up @@ -146,13 +147,18 @@ func (r *AccountResource) Schema(_ context.Context, _ resource.SchemaRequest, re
Description: "Billing email to apply to the account's Stripe customer",
Optional: true,
},
"domain_names": schema.ListAttribute{
Description: "The list of domain names for enabling SSO in Prefect Cloud.",
ElementType: types.StringType,
Optional: true,
},
},
}
}

// copyAccountToModel maps an API response to a model that is saved in Terraform state.
// A model can be a Terraform Plan, State, or Config object.
func copyAccountToModel(_ context.Context, account *api.AccountResponse, tfModel *AccountResourceModel) diag.Diagnostics {
func copyAccountToModel(_ context.Context, account *api.Account, tfModel *AccountResourceModel) diag.Diagnostics {
tfModel.ID = types.StringValue(account.ID.String())
tfModel.Created = customtypes.NewTimestampPointerValue(account.Created)
tfModel.Updated = customtypes.NewTimestampPointerValue(account.Updated)
Expand Down Expand Up @@ -181,6 +187,18 @@ func copyAccountToModel(_ context.Context, account *api.AccountResponse, tfModel
return diags
}

// copyAccountDomainsToModel maps an API response to a model that is saved in Terraform state.
// A model can be a Terraform Plan, State, or Config object.
func copyAccountDomainsToModel(ctx context.Context, accountDomains *api.AccountDomainsUpdate, tfModel *AccountResourceModel) diag.Diagnostics {
domainNames, diags := types.ListValueFrom(ctx, types.StringType, accountDomains.DomainNames)
if diags.HasError() {
return diags
}
tfModel.DomainNames = domainNames

return nil
}

func (r *AccountResource) Create(_ context.Context, _ resource.CreateRequest, resp *resource.CreateResponse) {
resp.Diagnostics.AddError("Cannot create account", "Account is an import-only resource and cannot be created by Terraform.")
}
Expand Down Expand Up @@ -219,6 +237,18 @@ func (r *AccountResource) Read(ctx context.Context, req resource.ReadRequest, re
return
}

accountDomains, err := client.GetDomains(ctx)
if err != nil {
resp.Diagnostics.Append(helpers.ResourceClientErrorDiagnostic("Account domains", "get", err))

return
}

resp.Diagnostics.Append(copyAccountDomainsToModel(ctx, accountDomains, &state)...)
if resp.Diagnostics.HasError() {
return
}

resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
Expand Down Expand Up @@ -247,8 +277,8 @@ func (r *AccountResource) Update(ctx context.Context, req resource.UpdateRequest
}

err = client.Update(ctx, api.AccountUpdate{
Name: plan.Name.ValueStringPointer(),
Handle: plan.Handle.ValueStringPointer(),
Name: plan.Name.ValueString(),
Handle: plan.Handle.ValueString(),
Location: plan.Location.ValueStringPointer(),
Link: plan.Link.ValueStringPointer(),
BillingEmail: plan.BillingEmail.ValueStringPointer(),
Expand Down Expand Up @@ -278,6 +308,21 @@ func (r *AccountResource) Update(ctx context.Context, req resource.UpdateRequest
}
}

// If domains have changed, we need to create a separate request to update them.
if !plan.DomainNames.Equal(state.DomainNames) {
var domainNames []string
resp.Diagnostics.Append(plan.DomainNames.ElementsAs(ctx, &domainNames, false)...)

err = client.UpdateDomains(ctx, api.AccountDomainsUpdate{
DomainNames: domainNames,
})
if err != nil {
resp.Diagnostics.Append(helpers.ResourceClientErrorDiagnostic("Account domains", "update", err))

return
}
}

account, err := client.Get(ctx)
if err != nil {
resp.Diagnostics.Append(helpers.ResourceClientErrorDiagnostic("Account", "get", err))
Expand Down
10 changes: 5 additions & 5 deletions internal/provider/resources/service_account.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,9 @@ func (r *ServiceAccountResource) Schema(_ context.Context, _ resource.SchemaRequ
}
}

// copyServiceAccountResponseToModel maps an API response to a model that is saved in Terraform state.
// copyServiceAccountToModel maps an API response to a model that is saved in Terraform state.
// A model can be a Terraform Plan, State, or Config object.
func copyServiceAccountResponseToModel(serviceAccount *api.ServiceAccount, tfModel *ServiceAccountResourceModel) {
func copyServiceAccountToModel(serviceAccount *api.ServiceAccount, tfModel *ServiceAccountResourceModel) {
// NOTE: the API Key is attached to the resource model outside of this helper,
// as it is only returned on Create/Update operations.
tfModel.ID = types.StringValue(serviceAccount.ID.String())
Expand Down Expand Up @@ -267,7 +267,7 @@ func (r *ServiceAccountResource) Create(ctx context.Context, req resource.Create
return
}

copyServiceAccountResponseToModel(serviceAccount, &plan)
copyServiceAccountToModel(serviceAccount, &plan)

// The API Key is only returned on Create or when rotating the key, so we'll attach it to
// the model outside of the helper function, so that we can prevent the value from being
Expand Down Expand Up @@ -344,7 +344,7 @@ func (r *ServiceAccountResource) Read(ctx context.Context, req resource.ReadRequ
return
}

copyServiceAccountResponseToModel(serviceAccount, &state)
copyServiceAccountToModel(serviceAccount, &state)

resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
if resp.Diagnostics.HasError() {
Expand Down Expand Up @@ -461,7 +461,7 @@ func (r *ServiceAccountResource) Update(ctx context.Context, req resource.Update
}

// Update the model with latest service account details (from the Get call above)
copyServiceAccountResponseToModel(serviceAccount, &plan)
copyServiceAccountToModel(serviceAccount, &plan)

// The API Key is only returned on Create or when rotating the key, so we'll attach it to
// the model outside of the helper function, so that we can prevent the value from being
Expand Down

0 comments on commit 0e8c122

Please sign in to comment.