diff --git a/client/team.go b/client/team.go index ee0f9db1..15a800e8 100644 --- a/client/team.go +++ b/client/team.go @@ -11,48 +11,69 @@ import ( type TeamCreateRequest struct { Slug string `json:"slug"` Name string `json:"name"` + Plan string `json:"plan"` } -// Team is the information returned by the vercel api when a team is created. -type Team struct { - ID string `json:"id"` - SensitiveEnvironmentVariablePolicy *string `json:"sensitiveEnvironmentVariablePolicy"` +type SamlConnection struct { + Status string `json:"status"` } -// CreateTeam creates a team within vercel. -func (c *Client) CreateTeam(ctx context.Context, request TeamCreateRequest) (r Team, err error) { - url := fmt.Sprintf("%s/v1/teams", c.baseURL) +type SamlDirectory struct { + Type string `json:"type"` + State string `json:"state"` +} - payload := string(mustMarshal(request)) - tflog.Info(ctx, "creating team", map[string]interface{}{ - "url": url, - "payload": payload, - }) - err = c.doRequest(clientRequest{ - ctx: ctx, - method: "POST", - url: url, - body: payload, - }, &r) - return r, err +type SamlConfig struct { + Connection *SamlConnection `json:"connection"` + Directory *SamlDirectory `json:"directory"` + Enforced bool `json:"enforced,omitempty"` + Roles map[string]string `json:"roles,omitempty"` } -// DeleteTeam deletes an existing team within vercel. -func (c *Client) DeleteTeam(ctx context.Context, teamID string) error { - url := fmt.Sprintf("%s/v1/teams/%s", c.baseURL, teamID) - tflog.Info(ctx, "deleting team", map[string]interface{}{ - "url": url, - }) - return c.doRequest(clientRequest{ - ctx: ctx, - method: "DELETE", - url: url, - body: "", - }, nil) +type TaxID struct { + Type string `json:"type"` + ID string `json:"id"` +} + +type Address struct { + Line1 *string `json:"line1"` + Line2 *string `json:"line2"` + PostalCode *string `json:"postalCode"` + City *string `json:"city"` + Country *string `json:"country"` + State *string `json:"state"` +} + +type RemoteCaching struct { + Enabled *bool `json:"enabled"` +} + +type SpacesConfig struct { + Enabled bool `json:"enabled"` +} + +// Team is the information returned by the vercel api when a team is created. +type Team struct { + ID string `json:"id"` + Name string `json:"name"` + Avatar *string `json:"avatar"` // hash of uploaded image + Description *string `json:"description"` + Slug string `json:"slug"` + SensitiveEnvironmentVariablePolicy *string `json:"sensitiveEnvironmentVariablePolicy"` + EmailDomain *string `json:"emailDomain"` + Saml *SamlConfig `json:"saml"` + InviteCode *string `json:"inviteCode"` + PreviewDeploymentSuffix *string `json:"previewDeploymentSuffix"` + RemoteCaching *RemoteCaching `json:"remoteCaching"` + EnablePreviewFeedback *string `json:"enablePreviewFeedback"` + EnableProductionFeedback *string `json:"enableProductionFeedback"` + Spaces *SpacesConfig `json:"spaces"` + HideIPAddresses *bool `json:"hideIpAddresses"` + HideIPAddressesInLogDrains *bool `json:"hideIpAddressesInLogDrains,omitempty"` } // GetTeam returns information about an existing team within vercel. -func (c *Client) GetTeam(ctx context.Context, idOrSlug string) (r Team, err error) { +func (c *Client) GetTeam(ctx context.Context, idOrSlug string) (t Team, err error) { url := fmt.Sprintf("%s/v2/teams/%s", c.baseURL, idOrSlug) tflog.Info(ctx, "getting team", map[string]interface{}{ "url": url, @@ -62,6 +83,44 @@ func (c *Client) GetTeam(ctx context.Context, idOrSlug string) (r Team, err erro method: "GET", url: url, body: "", - }, &r) - return r, err + }, &t) + return t, err +} + +type UpdateSamlConfig struct { + Enforced bool `json:"enforced"` + Roles map[string]string `json:"roles"` +} + +type UpdateTeamRequest struct { + TeamID string `json:"-"` + Avatar string `json:"avatar,omitempty"` + Description string `json:"description,omitempty"` + EmailDomain string `json:"emailDomain,omitempty"` + Name string `json:"name,omitempty"` + PreviewDeploymentSuffix string `json:"previewDeploymentSuffix,omitempty"` + Saml *UpdateSamlConfig `json:"saml,omitempty"` + Slug string `json:"slug,omitempty"` + EnablePreviewFeedback string `json:"enablePreviewFeedback,omitempty"` + EnableProductionFeedback string `json:"enableProductionFeedback,omitempty"` + SensitiveEnvironmentVariablePolicy string `json:"sensitiveEnvironmentVariablePolicy,omitempty"` + RemoteCaching *RemoteCaching `json:"remoteCaching,omitempty"` + HideIPAddresses *bool `json:"hideIpAddresses,omitempty"` + HideIPAddressesInLogDrains *bool `json:"hideIpAddressesInLogDrains,omitempty"` +} + +func (c *Client) UpdateTeam(ctx context.Context, request UpdateTeamRequest) (t Team, err error) { + url := fmt.Sprintf("%s/v2/teams/%s", c.baseURL, request.TeamID) + payload := string(mustMarshal(request)) + tflog.Info(ctx, "updating team", map[string]interface{}{ + "url": url, + "payload": payload, + }) + err = c.doRequest(clientRequest{ + ctx: ctx, + method: "PATCH", + url: url, + body: payload, + }, &t) + return t, err } diff --git a/docs/resources/team_config.md b/docs/resources/team_config.md new file mode 100644 index 00000000..53731774 --- /dev/null +++ b/docs/resources/team_config.md @@ -0,0 +1,68 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_team_config Resource - terraform-provider-vercel" +subcategory: "" +description: |- + Manages the configuration of an existing Vercel Team. +--- + +# vercel_team_config (Resource) + +Manages the configuration of an existing Vercel Team. + +## Example Usage + +```terraform +data "vercel_file" "example" { + path = "example/avatar.png" +} + +resource "vercel_team_config" "example" { + id = "team_xxxxxxxxxxxxxxxxxxxxxxxx" + avatar = data.vercel_file.example.file + name = "Vercel terraform example" + slug = "vercel-terraform-example" + description = "Vercel Terraform Example" + sensitive_environment_variable_policy = "off" + remote_caching = { + enabled = true + } + enable_preview_feedback = "off" + enable_production_feedback = "off" + hide_ip_addresses = true + hide_ip_addresses_in_log_drains = true +} +``` + + +## Schema + +### Required + +- `id` (String) The ID of the existing Vercel Team. + +### Optional + +- `avatar` (Map of String) The `avatar` should be a the 'file' attribute from a vercel_file data source. +- `description` (String) A description of the team. +- `email_domain` (String) Hostname that'll be matched with emails on sign-up to automatically join the Team. +- `enable_preview_feedback` (String) +- `enable_production_feedback` (String) +- `hide_ip_addresses` (Boolean) Indicates if ip addresses should be accessible in o11y tooling. +- `hide_ip_addresses_in_log_drains` (Boolean) Indicates if ip addresses should be accessible in log drains. +- `name` (String) The name of the team. +- `preview_deployment_suffix` (String) The hostname that is used as the preview deployment suffix. +- `remote_caching` (Attributes) Configuration for Remote Caching. (see [below for nested schema](#nestedatt--remote_caching)) +- `sensitive_environment_variable_policy` (String) +- `slug` (String) The slug of the team. Will be used in the URL of the team's dashboard. + +### Read-Only + +- `invite_code` (String) A code that can be used to join this team. Only visible to Team owners. + + +### Nested Schema for `remote_caching` + +Optional: + +- `enabled` (Boolean) Indicates if Remote Caching is enabled. diff --git a/examples/resources/vercel_team_config/resource.tf b/examples/resources/vercel_team_config/resource.tf new file mode 100644 index 00000000..97efbd4b --- /dev/null +++ b/examples/resources/vercel_team_config/resource.tf @@ -0,0 +1,19 @@ +data "vercel_file" "example" { + path = "example/avatar.png" +} + +resource "vercel_team_config" "example" { + id = "team_xxxxxxxxxxxxxxxxxxxxxxxx" + avatar = data.vercel_file.example.file + name = "Vercel terraform example" + slug = "vercel-terraform-example" + description = "Vercel Terraform Example" + sensitive_environment_variable_policy = "off" + remote_caching = { + enabled = true + } + enable_preview_feedback = "off" + enable_production_feedback = "off" + hide_ip_addresses = true + hide_ip_addresses_in_log_drains = true +} diff --git a/vercel/examples/avatar.png b/vercel/examples/avatar.png new file mode 100644 index 00000000..2de31315 Binary files /dev/null and b/vercel/examples/avatar.png differ diff --git a/vercel/provider.go b/vercel/provider.go index c03076d9..075378ce 100644 --- a/vercel/provider.go +++ b/vercel/provider.go @@ -65,6 +65,7 @@ func (p *vercelProvider) Resources(_ context.Context) []func() resource.Resource newProjectEnvironmentVariableResource, newProjectResource, newSharedEnvironmentVariableResource, + newTeamConfigResource, newWebhookResource, } } diff --git a/vercel/resource_team_config.go b/vercel/resource_team_config.go new file mode 100644 index 00000000..9e5924a5 --- /dev/null +++ b/vercel/resource_team_config.go @@ -0,0 +1,522 @@ +package vercel + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/vercel/terraform-provider-vercel/client" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &teamConfigResource{} + _ resource.ResourceWithConfigure = &teamConfigResource{} +) + +func newTeamConfigResource() resource.Resource { + return &teamConfigResource{} +} + +type teamConfigResource struct { + client *client.Client +} + +func (r *teamConfigResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_team_config" +} + +func (r *teamConfigResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.client = client +} + +func (r *teamConfigResource) Schema(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages the configuration of an existing Vercel Team.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The ID of the existing Vercel Team.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the team.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "slug": schema.StringAttribute{ + Description: "The slug of the team. Will be used in the URL of the team's dashboard.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "avatar": schema.MapAttribute{ + Description: "The `avatar` should be a the 'file' attribute from a vercel_file data source.", + Optional: true, + PlanModifiers: []planmodifier.Map{mapplanmodifier.RequiresReplace()}, + ElementType: types.StringType, + Validators: []validator.Map{ + mapItemsMinCount(1), + mapItemsMaxCount(1), + }, + }, + "description": schema.StringAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + Description: "A description of the team.", + }, + "sensitive_environment_variable_policy": schema.StringAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + Validators: []validator.String{ + stringOneOf("on", "off"), + }, + }, + "email_domain": schema.StringAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + Description: "Hostname that'll be matched with emails on sign-up to automatically join the Team.", + }, + /* + "saml": schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "enforced": schema.BoolAttribute{ + Description: "Indicates if SAML is enforced for the team.", + Required: true, + }, + "roles": schema.MapAttribute{ + Description: "Directory groups to role or access group mappings.", + Optional: true, + }, + "access_group_id": schema.StringAttribute{ + // TODO - enforce either accessGroupId or roles. + Description: "The ID of the access group to use for the team.", + Optional: true, + Validators: []validator.String{ + stringRegex(regexp.MustCompile("^ag_[A-z0-9_ -]+$"), "Access group ID must be a valid access group"), + }, + }, + "connection": schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "status": schema.StringAttribute{ + Computed: true, + Description: "The current status of the connection.", + }, + }, + Description: "Info about the SAML connection.", + Computed: true, + }, + "directory": schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "type": schema.StringAttribute{ + Computed: true, + Description: "The identity provider type.", + }, + "state": schema.StringAttribute{ + Computed: true, + Description: "The current state of the SAML connection.", + }, + }, + Description: "Info about the SAML directory.", + Computed: true, + }, + }, + Optional: true, + Description: "Configuration for SAML authentication.", + }, + */ + "invite_code": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + Description: "A code that can be used to join this team. Only visible to Team owners.", + }, + "preview_deployment_suffix": schema.StringAttribute{ + Optional: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + Computed: true, + Description: "The hostname that is used as the preview deployment suffix.", + }, + "remote_caching": schema.SingleNestedAttribute{ + Description: "Configuration for Remote Caching.", + Optional: true, + Computed: true, + Attributes: map[string]schema.Attribute{ + "enabled": schema.BoolAttribute{ + Description: "Indicates if Remote Caching is enabled.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{boolplanmodifier.UseStateForUnknown()}, + }, + }, + }, + "enable_preview_feedback": schema.StringAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + Validators: []validator.String{ + stringOneOf("default", "on", "off"), + }, + }, + "enable_production_feedback": schema.StringAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + Validators: []validator.String{ + stringOneOf("default", "on", "off"), + }, + }, + "hide_ip_addresses": schema.BoolAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{boolplanmodifier.UseStateForUnknown()}, + Description: "Indicates if ip addresses should be accessible in o11y tooling.", + }, + "hide_ip_addresses_in_log_drains": schema.BoolAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{boolplanmodifier.UseStateForUnknown()}, + Description: "Indicates if ip addresses should be accessible in log drains.", + }, + }, + } +} + +/* +type SamlConnection struct { + Status types.String `tfsdk:"status"` +} + +type SamlDirectory struct { + Type types.String `tfsdk:"type"` + State types.String `tfsdk:"state"` +} + +type Saml struct { + Enforced types.Bool `tfsdk:"enforced"` + Roles types.Map `tfsdk:"roles"` + AccessGroupId types.String `tfsdk:"access_group_id"` + Connection *SamlConnection `tfsdk:"connection"` + Directory *SamlDirectory `tfsdk:"directory"` +} + +func (s Saml) toUpdateSamlConfig() *client.UpdateSamlConfig { +} +*/ + +type EnableConfig struct { + Enabled types.Bool `tfsdk:"enabled"` +} + +type TeamConfig struct { + ID types.String `tfsdk:"id"` + Avatar types.Map `tfsdk:"avatar"` + Name types.String `tfsdk:"name"` + Slug types.String `tfsdk:"slug"` + Description types.String `tfsdk:"description"` + InviteCode types.String `tfsdk:"invite_code"` + SensitiveEnvironmentVariablePolicy types.String `tfsdk:"sensitive_environment_variable_policy"` + EmailDomain types.String `tfsdk:"email_domain"` + PreviewDeploymentSuffix types.String `tfsdk:"preview_deployment_suffix"` + RemoteCaching types.Object `tfsdk:"remote_caching"` + EnablePreviewFeedback types.String `tfsdk:"enable_preview_feedback"` + EnableProductionFeedback types.String `tfsdk:"enable_production_feedback"` + HideIPAddresses types.Bool `tfsdk:"hide_ip_addresses"` + HideIPAddressesInLogDrains types.Bool `tfsdk:"hide_ip_addresses_in_log_drains"` + // Saml types.Object `tfsdk:"saml"` +} + +type RemoteCaching struct { + Enabled types.Bool `tfsdk:"enabled"` +} + +var remoteCachingAttrTypes = map[string]attr.Type{ + "enabled": types.BoolType, +} + +func (r *RemoteCaching) toUpdateTeamRequest() *client.RemoteCaching { + if r == nil { + return nil + } + return &client.RemoteCaching{ + Enabled: r.Enabled.ValueBoolPointer(), + } +} + +func (t *TeamConfig) toUpdateTeamRequest(ctx context.Context, avatar string, stateSlug types.String) (client.UpdateTeamRequest, diag.Diagnostics) { + slug := t.Slug.ValueString() + if stateSlug.ValueString() == t.Slug.ValueString() { + // Prevent updating slug if it hasn't changed, as this has an aggressive rate limit. + slug = "" + } + var rc *RemoteCaching + diags := t.RemoteCaching.As(ctx, &rc, basetypes.ObjectAsOptions{ + UnhandledNullAsEmpty: true, + UnhandledUnknownAsEmpty: true, + }) + if diags.HasError() { + return client.UpdateTeamRequest{}, diags + } + + return client.UpdateTeamRequest{ + TeamID: t.ID.ValueString(), + Avatar: avatar, + EmailDomain: t.EmailDomain.ValueString(), + Name: t.Name.ValueString(), + Description: t.Description.ValueString(), + PreviewDeploymentSuffix: t.PreviewDeploymentSuffix.ValueString(), + Slug: slug, + EnablePreviewFeedback: t.EnablePreviewFeedback.ValueString(), + EnableProductionFeedback: t.EnableProductionFeedback.ValueString(), + SensitiveEnvironmentVariablePolicy: t.SensitiveEnvironmentVariablePolicy.ValueString(), + RemoteCaching: rc.toUpdateTeamRequest(), + HideIPAddresses: t.HideIPAddresses.ValueBoolPointer(), + HideIPAddressesInLogDrains: t.HideIPAddressesInLogDrains.ValueBoolPointer(), + // Saml: t.Saml.toUpdateSamlConfig(), + }, nil +} + +func convertResponseToTeamConfig(ctx context.Context, response client.Team, avatar types.Map) (TeamConfig, diag.Diagnostics) { + remoteCaching := types.ObjectNull(remoteCachingAttrTypes) + if response.RemoteCaching != nil { + var diags diag.Diagnostics + remoteCaching, diags = types.ObjectValueFrom(ctx, remoteCachingAttrTypes, &RemoteCaching{ + Enabled: types.BoolPointerValue(response.RemoteCaching.Enabled), + }) + if diags.HasError() { + return TeamConfig{}, diags + } + } + return TeamConfig{ + Avatar: avatar, + ID: types.StringValue(response.ID), + Name: types.StringValue(response.Name), + Slug: types.StringValue(response.Slug), + Description: types.StringPointerValue(response.Description), + InviteCode: types.StringPointerValue(response.InviteCode), + SensitiveEnvironmentVariablePolicy: types.StringPointerValue(response.SensitiveEnvironmentVariablePolicy), + EmailDomain: types.StringPointerValue(response.EmailDomain), + PreviewDeploymentSuffix: types.StringPointerValue(response.PreviewDeploymentSuffix), + EnablePreviewFeedback: types.StringPointerValue(response.EnablePreviewFeedback), + EnableProductionFeedback: types.StringPointerValue(response.EnableProductionFeedback), + HideIPAddresses: types.BoolPointerValue(response.HideIPAddresses), + HideIPAddressesInLogDrains: types.BoolPointerValue(response.HideIPAddressesInLogDrains), + RemoteCaching: remoteCaching, + // Saml: types.StringValue(response.Saml), + }, nil +} + +func (r *teamConfigResource) uploadAvatarIfPresent(ctx context.Context, plan TeamConfig) (avatar string, diags diag.Diagnostics) { + if !plan.Avatar.IsNull() && !plan.Avatar.IsUnknown() { + var unparsedFiles map[string]string + diags = plan.Avatar.ElementsAs(ctx, &unparsedFiles, false) + if diags.HasError() { + return avatar, diags + } + for filename, rawSizeAndSha := range unparsedFiles { + sizeSha := strings.Split(rawSizeAndSha, "~") + if len(sizeSha) != 2 { + diags.AddError( + "Error creating team config", + "Could not parse avatar, unexpected error: expected avatar to have format filename: size~sha, but could not parse", + ) + return avatar, diags + } + + sha := sizeSha[1] + content, err := os.ReadFile(filename) + if err != nil { + diags.AddError( + "Error reading avatar", + fmt.Sprintf( + "Could not read file %s, unexpected error: %s", + filename, + err, + ), + ) + return avatar, diags + } + err = r.client.CreateFile(ctx, client.CreateFileRequest{ + Filename: normaliseFilename(filename, types.StringNull()), + SHA: sha, + Content: string(content), + TeamID: plan.ID.ValueString(), + }) + if err != nil { + diags.AddError( + "Error uploading avatar", + fmt.Sprintf( + "Could not upload avatar %s, unexpected error: %s", + filename, + err, + ), + ) + return avatar, diags + } + return sha, nil + } + } + return "", nil +} + +func (r *teamConfigResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan TeamConfig + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + avatar, diags := r.uploadAvatarIfPresent(ctx, plan) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + request, diags := plan.toUpdateTeamRequest(ctx, avatar, types.StringNull()) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + response, err := r.client.UpdateTeam(ctx, request) + if err != nil { + resp.Diagnostics.AddError( + "Error creating Team Config", + "Could not create Team Configuration, unexpected error: "+err.Error(), + ) + return + } + + tflog.Info(ctx, "updated Team Configuration", map[string]interface{}{ + "team_id": response.ID, + }) + + teamConfig, diags := convertResponseToTeamConfig(ctx, response, plan.Avatar) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + diags = resp.State.Set(ctx, teamConfig) + resp.Diagnostics.Append(diags...) +} + +func (r *teamConfigResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state TeamConfig + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.client.GetTeam(ctx, state.ID.ValueString()) + if client.NotFound(err) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error reading Team", + "Could not read Team Configuration, unexpected error: "+err.Error(), + ) + return + } + + result, diags := convertResponseToTeamConfig(ctx, out, state.Avatar) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) +} + +func (r *teamConfigResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan TeamConfig + diags := req.Plan.Get(ctx, &plan) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + var state TeamConfig + diags = req.State.Get(ctx, &state) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + avatar, diags := r.uploadAvatarIfPresent(ctx, plan) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + request, diags := plan.toUpdateTeamRequest(ctx, avatar, state.Slug) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + response, err := r.client.UpdateTeam(ctx, request) + if err != nil { + resp.Diagnostics.AddError( + "Error creating Team Config", + "Could not create Team Configuration, unexpected error: "+err.Error(), + ) + return + } + + tflog.Info(ctx, "updated Team configuration", map[string]interface{}{ + "team_id": response.ID, + }) + + teamConfig, diags := convertResponseToTeamConfig(ctx, response, plan.Avatar) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + diags = resp.State.Set(ctx, teamConfig) + resp.Diagnostics.Append(diags...) +} + +func (r *teamConfigResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state TeamConfig + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // We don't actually delete the team, just remove it from state + resp.State.RemoveResource(ctx) +} diff --git a/vercel/resource_team_config_test.go b/vercel/resource_team_config_test.go new file mode 100644 index 00000000..3bda442c --- /dev/null +++ b/vercel/resource_team_config_test.go @@ -0,0 +1,75 @@ +package vercel_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAcc_TeamConfig(t *testing.T) { + resourceName := "vercel_team_config.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccVercelTeamConfigBasic(testTeam()), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "name", "vercel-terraform-test"), + resource.TestCheckResourceAttr(resourceName, "slug", "vercel-terraform-test-ci"), + resource.TestCheckResourceAttrSet(resourceName, "id"), + ), + }, + { + Config: testAccVercelTeamConfigUpdated(testTeam()), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttr(resourceName, "name", "vercel-terraform-test-ci"), + resource.TestCheckResourceAttr(resourceName, "slug", "vercel-terraform-test-ci"), + resource.TestCheckResourceAttr(resourceName, "description", "Vercel Terraform Testing"), + resource.TestCheckResourceAttr(resourceName, "sensitive_environment_variable_policy", "off"), + resource.TestCheckResourceAttr(resourceName, "remote_caching.enabled", "true"), + resource.TestCheckResourceAttr(resourceName, "enable_preview_feedback", "off"), + resource.TestCheckResourceAttr(resourceName, "enable_production_feedback", "off"), + resource.TestCheckResourceAttr(resourceName, "hide_ip_addresses", "true"), + resource.TestCheckResourceAttr(resourceName, "hide_ip_addresses_in_log_drains", "true"), + ), + }, + }, + }) +} + +func testAccVercelTeamConfigBasic(teamID string) string { + return fmt.Sprintf(` +resource "vercel_team_config" "test" { + id = "%s" // Replace with a valid team ID + name = "vercel-terraform-test" +} +`, teamID) +} + +func testAccVercelTeamConfigUpdated(teamID string) string { + return fmt.Sprintf(` +data "vercel_file" "test" { + path = "examples/avatar.png" +} + +resource "vercel_team_config" "test" { + id = "%s" + avatar = data.vercel_file.test.file + name = "vercel-terraform-test-ci" + slug = "vercel-terraform-test-ci" + description = "Vercel Terraform Testing" + sensitive_environment_variable_policy = "off" + remote_caching = { + enabled = true + } + enable_preview_feedback = "off" + enable_production_feedback = "off" + hide_ip_addresses = true + hide_ip_addresses_in_log_drains = true +} +`, teamID) +} diff --git a/vercel/validator_map_items_max_count.go b/vercel/validator_map_items_max_count.go new file mode 100644 index 00000000..c9f17757 --- /dev/null +++ b/vercel/validator_map_items_max_count.go @@ -0,0 +1,44 @@ +package vercel + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func mapItemsMaxCount(minCount int) validatorMapItemsMaxCount { + return validatorMapItemsMaxCount{ + Max: minCount, + } +} + +type validatorMapItemsMaxCount struct { + Max int +} + +func (v validatorMapItemsMaxCount) Description(ctx context.Context) string { + return fmt.Sprintf("Map must contain %d or more item(s)", v.Max) +} +func (v validatorMapItemsMaxCount) MarkdownDescription(ctx context.Context) string { + return fmt.Sprintf("Map must contain %d or more item(s)", v.Max) +} + +func (v validatorMapItemsMaxCount) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + count := len(req.ConfigValue.Elements()) + if count > v.Max { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid value provided", + fmt.Sprintf( + "Map must contain no more than %d items, got: %d.", + v.Max, + count, + ), + ) + return + } +} diff --git a/vercel/validator_map_items_min_count.go b/vercel/validator_map_items_min_count.go index a6c68dd8..1456d0cd 100644 --- a/vercel/validator_map_items_min_count.go +++ b/vercel/validator_map_items_min_count.go @@ -14,7 +14,6 @@ func mapItemsMinCount(minCount int) validatorMapItemsMinCount { } type validatorMapItemsMinCount struct { - Max int Min int }