Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Teams initial support #146

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# MacOS
.DS_Store

# Local Terraform provider cache and state
.terraform
terraform.tfstate
Expand Down
1 change: 0 additions & 1 deletion .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,6 @@ linters-settings:
- sliceClear
- sloppyLen
- sloppyReassign
- sloppyTestFuncName
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

- sloppyTypeAssert
- sortSlice
- sprintfQuotedString
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Check back with us to see new additions and improvements - and please don't hesi
| Account Role | ✓ | | |
| Account | ✓ | ✓ | ✓ |
| Service Account | ✓ | ✓ | ✓ |
| Team | ✓ | | |
| Variable | ✓ | ✓ | ✓ |
| Work Pool | ✓ | ✓ | ✓ |
| Workspace Access | ✓ | ✓ | |
Expand Down
10 changes: 9 additions & 1 deletion docs/resources/workspace_access.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ resource "prefect_workspace_access" "bot_developer" {
workspace_id = "00000000-0000-0000-0000-000000000000"
workspace_role_id = data.prefect_workspace_role.developer.id
}

# Assign the Workspace Role to the Team
resource "prefect_workspace_access" "team_developer" {
accessor_type = "TEAM"
accessor_id = "11111111-1111-1111-1111-111111111111"
workspace_id = "00000000-0000-0000-0000-000000000000"
workspace_role_id = data.prefect_workspace_role.developer.id
}
```

<!-- schema generated by tfplugindocs -->
Expand All @@ -56,7 +64,7 @@ resource "prefect_workspace_access" "bot_developer" {
### Required

- `accessor_id` (String) ID (UUID) of accessor to the workspace. This can be an `account_member.user_id` or `service_account.id`
- `accessor_type` (String) USER or SERVICE_ACCOUNT
- `accessor_type` (String) USER | SERVICE_ACCOUNT | TEAM
- `workspace_role_id` (String) Workspace Role ID (UUID) to grant to accessor

### Optional
Expand Down
3 changes: 3 additions & 0 deletions examples/data-sources/prefect_team/data-source.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
data "prefect_team" "my_team" {
name = "my-team"
}
2 changes: 2 additions & 0 deletions examples/data-sources/prefect_teams/data-source.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Query all Teams in Account
data "prefect_teams" "all_teams" {}
10 changes: 10 additions & 0 deletions examples/resources/prefect_workspace_access/resource.tf
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,13 @@ resource "prefect_workspace_access" "bot_developer" {
workspace_id = "00000000-0000-0000-0000-000000000000"
workspace_role_id = data.prefect_workspace_role.developer.id
}

# ASSIGNING WORKSPACE ACCESS TO A TEAM

# Assign the Workspace Role to the Team
resource "prefect_workspace_access" "team_developer" {
accessor_type = "TEAM"
accessor_id = "11111111-1111-1111-1111-111111111111"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
accessor_id = "11111111-1111-1111-1111-111111111111"
accessor_id = prefect_account_member.marvin.user_id

just to reinforce that accessor_id is either a service account ID or an account member ID (eg a user). "accessor" is kind of an abstract term we chose here to unify the bot/user domains, so it might not be immediately clear to the TF user

workspace_id = "00000000-0000-0000-0000-000000000000"
workspace_role_id = data.prefect_workspace_role.developer.id
}
1 change: 1 addition & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type PrefectClient interface {
AccountMemberships(accountID uuid.UUID) (AccountMembershipsClient, error)
AccountRoles(accountID uuid.UUID) (AccountRolesClient, error)
Collections() (CollectionsClient, error)
Teams(accountID uuid.UUID) (TeamsClient, error)
Workspaces(accountID uuid.UUID) (WorkspacesClient, error)
WorkspaceAccess(accountID uuid.UUID, workspaceID uuid.UUID) (WorkspaceAccessClient, error)
WorkspaceRoles(accountID uuid.UUID) (WorkspaceRolesClient, error)
Expand Down
29 changes: 29 additions & 0 deletions internal/api/teams.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package api

import (
"context"
)

// TeamsClient is a client for working with teams.
type TeamsClient interface {
List(ctx context.Context, names []string) ([]*Team, error)
}

// Team is a representation of an team.
type Team struct {
BaseModel
Name string `json:"name"`
Description string `json:"description"`
}

// TeamFilter defines the search filter payload
// when searching for team by name.
// example request payload:
// {"teams": {"name": {"any_": ["test"]}}}.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice

type TeamFilter struct {
Teams struct {
Name struct {
Any []string `json:"any_"`
} `json:"name"`
} `json:"teams"`
}
2 changes: 2 additions & 0 deletions internal/api/workspace_access.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type WorkspaceAccess struct {

ActorID *uuid.UUID `json:"actor_id"`
BotID *uuid.UUID `json:"bot_id"`
TeamID *uuid.UUID `json:"team_id"`
UserID *uuid.UUID `json:"user_id"`
}

Expand All @@ -35,6 +36,7 @@ type WorkspaceAccessUpsert struct {
// NOTE: omitempty normally excludes any zero value,
// for primitives, but complex types like structs
// and uuid.UUID require a pointer type to be omitted.
TeamID *uuid.UUID `json:"team_id,omitempty"`
UserID *uuid.UUID `json:"user_id,omitempty"`
BotID *uuid.UUID `json:"bot_id,omitempty"`
}
73 changes: 73 additions & 0 deletions internal/client/teams.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package client

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

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

var _ = api.TeamsClient(&TeamsClient{})

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

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

return &TeamsClient{
hc: c.hc,
apiKey: c.apiKey,
routePrefix: getAccountScopedURL(c.endpoint, accountID, "teams"),
}, nil
}

// List returns a list of teams, based on the provided filter.
func (c *TeamsClient) List(ctx context.Context, names []string) ([]*api.Team, error) {
var buf bytes.Buffer
filterQuery := api.TeamFilter{}
filterQuery.Teams.Name.Any = names

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 teams []*api.Team
if err := json.NewDecoder(resp.Body).Decode(&teams); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}

return teams, nil
}
10 changes: 10 additions & 0 deletions internal/client/workspace_access.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ func (c *WorkspaceAccessClient) Upsert(ctx context.Context, accessorType string,
requestPath = fmt.Sprintf("%s/bot_access/", c.routePrefix)
payload.BotID = &accessorID
}
if accessorType == utils.Team {
requestPath = fmt.Sprintf("%s/team_access/", c.routePrefix)
payload.TeamID = &accessorID
}

var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(&payload); err != nil {
Expand Down Expand Up @@ -98,6 +102,9 @@ func (c *WorkspaceAccessClient) Get(ctx context.Context, accessorType string, ac
if accessorType == utils.ServiceAccount {
requestPath = fmt.Sprintf("%s/bot_access/%s", c.routePrefix, accessID.String())
}
if accessorType == utils.Team {
requestPath = fmt.Sprintf("%s/team_access/%s", c.routePrefix, accessID.String())
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestPath, http.NoBody)
if err != nil {
Expand Down Expand Up @@ -134,6 +141,9 @@ func (c *WorkspaceAccessClient) Delete(ctx context.Context, accessorType string,
if accessorType == utils.ServiceAccount {
requestPath = fmt.Sprintf("%s/bot_access/%s", c.routePrefix, accessID.String())
}
if accessorType == utils.Team {
requestPath = fmt.Sprintf("%s/team_access/%s", c.routePrefix, accessID.String())
}

req, err := http.NewRequestWithContext(ctx, http.MethodDelete, requestPath, http.NoBody)
if err != nil {
Expand Down
169 changes: 169 additions & 0 deletions internal/provider/datasources/team.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package datasources

import (
"context"
"fmt"

"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"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 = &TeamDataSource{}
var _ datasource.DataSourceWithConfigure = &TeamDataSource{}

type TeamDataSource struct {
client api.PrefectClient
}

type TeamDataSourceModel struct {
ID customtypes.UUIDValue `tfsdk:"id"`
Created customtypes.TimestampValue `tfsdk:"created"`
Updated customtypes.TimestampValue `tfsdk:"updated"`
Name types.String `tfsdk:"first_name"`
Description types.String `tfsdk:"last_name"`

AccountID customtypes.UUIDValue `tfsdk:"account_id"`
}

// NewTeamDataSource returns a new TeamDataSource.
//
//nolint:ireturn // required by Terraform API
func NewTeamDataSource() datasource.DataSource {
return &TeamDataSource{}
}

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

// Shared set of schema attributes between team (singular)
// and teams (plural) datasources. Any team (singular)
// specific attributes will be added to a deep copy in the Schema method.
var teamAttributesBase = map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
CustomType: customtypes.UUIDType{},
Description: "Team ID (UUID)",
},
"created": schema.StringAttribute{
Computed: true,
CustomType: customtypes.TimestampType{},
Description: "Date and time of the team creation in RFC 3339 format",
},
"updated": schema.StringAttribute{
Computed: true,
CustomType: customtypes.TimestampType{},
Description: "Date and time that the team was last updated in RFC 3339 format",
},
"name": schema.StringAttribute{
Computed: true,
Description: "Name of Team",
},
"description": schema.StringAttribute{
Computed: true,
Description: "Description of team",
},
}

// Schema defines the schema for the data source.
func (d *TeamDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {

// Create a copy of the base attributes
// and add the account ID overrides here
// as they are not needed in the teams (plural) list
teamAttributes := make(map[string]schema.Attribute)
for k, v := range workPoolAttributesBase {
teamAttributes[k] = v
}
teamAttributes["account_id"] = schema.StringAttribute{
CustomType: customtypes.UUIDType{},
Description: "Account ID (UUID), defaults to the account set in the provider",
Optional: true,
}

resp.Schema = schema.Schema{
Description: `
Get information about an existing Team by their name.
<br>
Use this data source to obtain team IDs to manage Workspace Access.
`,
Attributes: teamAttributes,
}
}

// Configure adds the provider-configured client to the data source.
func (d *TeamDataSource) 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 *TeamDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
var config TeamDataSourceModel

// 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.Teams(config.AccountID.ValueUUID())
if err != nil {
resp.Diagnostics.Append(helpers.CreateClientErrorDiagnostic("Teams", err))

return
}

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

if len(teams) != 1 {
resp.Diagnostics.AddError(
"Could not find Team",
fmt.Sprintf("Could not find Team with name %s", config.Name.ValueString()),
)

return
}

fetchedTeam := teams[0]

config.ID = customtypes.NewUUIDValue(fetchedTeam.ID)
config.Created = customtypes.NewTimestampPointerValue(fetchedTeam.Created)
config.Updated = customtypes.NewTimestampPointerValue(fetchedTeam.Updated)
config.Name = types.StringValue(fetchedTeam.Name)
config.Description = types.StringValue(fetchedTeam.Description)

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