From b1370a36d58f84a947bcb9865792b40071c9eecb Mon Sep 17 00:00:00 2001 From: Douglas Harcourt Parsons Date: Fri, 1 Nov 2024 17:12:33 +0000 Subject: [PATCH] Add resource for `project_environment_variables` This resource can manage many environment variables for a project together. This allows users to avoid hitting the API rate limits when using the terraform provider. --- client/environment_variable.go | 77 ++-- client/project.go | 1 + client/request.go | 1 + docs/resources/project.md | 8 +- .../resources/project_environment_variable.md | 8 +- .../project_environment_variables.md | 89 +++++ .../resource.tf | 31 ++ vercel/provider.go | 1 + vercel/resource_project.go | 2 +- ...ource_project_environment_variable_test.go | 18 +- .../resource_project_environment_variables.go | 336 ++++++++++++------ ...urce_project_environment_variables_test.go | 131 +++++++ 12 files changed, 560 insertions(+), 143 deletions(-) create mode 100644 docs/resources/project_environment_variables.md create mode 100644 examples/resources/vercel_project_environment_variables/resource.tf create mode 100644 vercel/resource_project_environment_variables_test.go diff --git a/client/environment_variable.go b/client/environment_variable.go index bcdf7980..093daced 100644 --- a/client/environment_variable.go +++ b/client/environment_variable.go @@ -46,11 +46,16 @@ func (c *Client) CreateEnvironmentVariable(ctx context.Context, request CreateEn if err2 != nil { return e, err2 } - id, err3 := c.findConflictingEnvID(ctx, request.TeamID, request.ProjectID, conflictingEnv) - if err3 != nil { - return e, fmt.Errorf("%w %s", err, err3) + + envs, err3 := c.ListEnvironmentVariables(ctx, request.TeamID, request.ProjectID) + if err != nil { + return e, fmt.Errorf("%s: unable to list environment variables to detect conflict: %s", err, err3) + } + + id, found := findConflictingEnvID(request.TeamID, request.ProjectID, conflictingEnv, envs) + if found { + return e, fmt.Errorf("%w the conflicting environment variable ID is %s", err, id) } - return e, fmt.Errorf("%w the conflicting environment variable ID is %s", err, id) } if err != nil { return e, err @@ -89,22 +94,17 @@ func overlaps(s []string, e []string) bool { return false } -func (c *Client) findConflictingEnvID(ctx context.Context, teamID, projectID string, envConflict EnvConflictError) (string, error) { - envs, err := c.ListEnvironmentVariables(ctx, teamID, projectID) - if err != nil { - return "", fmt.Errorf("unable to list environment variables to detect conflict: %w", err) - } - +func findConflictingEnvID(teamID, projectID string, envConflict EnvConflictError, envs []EnvironmentVariable) (string, bool) { for _, env := range envs { if env.Key == envConflict.Key && overlaps(env.Target, envConflict.Target) && env.GitBranch == envConflict.GitBranch { id := fmt.Sprintf("%s/%s", projectID, env.ID) if teamID != "" { id = fmt.Sprintf("%s/%s", teamID, id) } - return id, nil + return id, true } } - return "", fmt.Errorf("conflicting environment variable not found") + return "", false } type CreateEnvironmentVariablesRequest struct { @@ -113,7 +113,20 @@ type CreateEnvironmentVariablesRequest struct { TeamID string } -func (c *Client) CreateEnvironmentVariables(ctx context.Context, request CreateEnvironmentVariablesRequest) error { +type CreateEnvironmentVariablesResponse struct { + Created []EnvironmentVariable `json:"created"` + Failed []struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Key string `json:"envVarKey"` + GitBranch *string `json:"gitBranch"` + Target []string `json:"target"` + } `json:"error"` + } `json:"failed"` +} + +func (c *Client) CreateEnvironmentVariables(ctx context.Context, request CreateEnvironmentVariablesRequest) ([]EnvironmentVariable, error) { url := fmt.Sprintf("%s/v10/projects/%s/env", c.baseURL, request.ProjectID) if c.teamID(request.TeamID) != "" { url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(request.TeamID)) @@ -123,27 +136,41 @@ func (c *Client) CreateEnvironmentVariables(ctx context.Context, request CreateE "url": url, "payload": payload, }) + + var response CreateEnvironmentVariablesResponse err := c.doRequest(clientRequest{ ctx: ctx, method: "POST", url: url, body: payload, - }, nil) - if conflictingEnv, isConflicting, err2 := conflictingEnvVar(err); isConflicting { - if err2 != nil { - return err2 + }, &response) + if err != nil { + return nil, err + } + + if len(response.Failed) > 0 { + envs, err := c.ListEnvironmentVariables(ctx, request.TeamID, request.ProjectID) + if err != nil { + return response.Created, fmt.Errorf("failed to create environment variables. error detecting conflicting environment variables: %w", err) } - id, err3 := c.findConflictingEnvID(ctx, request.TeamID, request.ProjectID, conflictingEnv) - if err3 != nil { - return fmt.Errorf("%w %s", err, err3) + for _, failed := range response.Failed { + if failed.Error.Code == "ENV_CONFLICT" { + id, found := findConflictingEnvID(request.TeamID, request.ProjectID, EnvConflictError{ + Key: failed.Error.Key, + Target: failed.Error.Target, + GitBranch: failed.Error.GitBranch, + }, envs) + if found { + err = fmt.Errorf("%w, conflicting environment variable ID is %s", err, id) + } + } else { + err = fmt.Errorf("failed to create environment variables, %s %s %s", failed.Error.Message, failed.Error.Key, failed.Error.Target) + } } - return fmt.Errorf("%w the conflicting environment variable ID is %s", err, id) - } - if err != nil { - return err + return response.Created, err } - return err + return response.Created, err } // UpdateEnvironmentVariableRequest defines the information that needs to be passed to Vercel in order to diff --git a/client/project.go b/client/project.go index e097432a..db33a540 100644 --- a/client/project.go +++ b/client/project.go @@ -31,6 +31,7 @@ type EnvironmentVariable struct { ID string `json:"id,omitempty"` TeamID string `json:"-"` Comment string `json:"comment"` + Decrypted bool `json:"decrypted"` } type DeploymentExpiration struct { diff --git a/client/request.go b/client/request.go index 50a60241..e5bb3509 100644 --- a/client/request.go +++ b/client/request.go @@ -111,6 +111,7 @@ func (c *Client) _doRequest(req *http.Request, v interface{}, errorOnNoContent b defer resp.Body.Close() responseBody, err := io.ReadAll(resp.Body) + if err != nil { return fmt.Errorf("error reading response body: %w", err) } diff --git a/docs/resources/project.md b/docs/resources/project.md index 0dc656d7..67966aa8 100644 --- a/docs/resources/project.md +++ b/docs/resources/project.md @@ -6,8 +6,8 @@ description: |- Provides a Project resource. A Project groups deployments and custom domains. To deploy on Vercel, you need to create a Project. For more detailed information, please see the Vercel documentation https://vercel.com/docs/concepts/projects/overview. - ~> Terraform currently provides both a standalone Project Environment Variable resource (a single Environment Variable), and a Project resource with Environment Variables defined in-line via the environment field. - At this time you cannot use a Vercel Project resource with in-line environment in conjunction with any vercel_project_environment_variable resources. Doing so will cause a conflict of settings and will overwrite Environment Variables. + ~> Terraform currently provides a standalone Project Environment Variable resource (a single Environment Variable), a Project Environment Variables resource (multiple Environment Variables), and this Project resource with Environment Variables defined in-line via the environment field. + At this time you cannot use a Vercel Project resource with in-line environment in conjunction with any vercel_project_environment_variables or vercel_project_environment_variable resources. Doing so will cause a conflict of settings and will overwrite Environment Variables. --- # vercel_project (Resource) @@ -18,8 +18,8 @@ A Project groups deployments and custom domains. To deploy on Vercel, you need t For more detailed information, please see the [Vercel documentation](https://vercel.com/docs/concepts/projects/overview). -~> Terraform currently provides both a standalone Project Environment Variable resource (a single Environment Variable), and a Project resource with Environment Variables defined in-line via the `environment` field. -At this time you cannot use a Vercel Project resource with in-line `environment` in conjunction with any `vercel_project_environment_variable` resources. Doing so will cause a conflict of settings and will overwrite Environment Variables. +~> Terraform currently provides a standalone Project Environment Variable resource (a single Environment Variable), a Project Environment Variables resource (multiple Environment Variables), and this Project resource with Environment Variables defined in-line via the `environment` field. +At this time you cannot use a Vercel Project resource with in-line `environment` in conjunction with any `vercel_project_environment_variables` or `vercel_project_environment_variable` resources. Doing so will cause a conflict of settings and will overwrite Environment Variables. ## Example Usage diff --git a/docs/resources/project_environment_variable.md b/docs/resources/project_environment_variable.md index a30baacf..ee3a62ea 100644 --- a/docs/resources/project_environment_variable.md +++ b/docs/resources/project_environment_variable.md @@ -6,8 +6,8 @@ description: |- Provides a Project Environment Variable resource. A Project Environment Variable resource defines an Environment Variable on a Vercel Project. For more detailed information, please see the Vercel documentation https://vercel.com/docs/concepts/projects/environment-variables. - ~> Terraform currently provides both a standalone Project Environment Variable resource (a single Environment Variable), and a Project resource with Environment Variables defined in-line via the environment field. - At this time you cannot use a Vercel Project resource with in-line environment in conjunction with any vercel_project_environment_variable resources. Doing so will cause a conflict of settings and will overwrite Environment Variables. + ~> Terraform currently provides this Project Environment Variable resource (a single Environment Variable), a Project Environment Variables resource (multiple Environment Variables), and a Project resource with Environment Variables defined in-line via the environment field. + At this time you cannot use a Vercel Project resource with in-line environment in conjunction with any vercel_project_environment_variables or vercel_project_environment_variable resources. Doing so will cause a conflict of settings and will overwrite Environment Variables. --- # vercel_project_environment_variable (Resource) @@ -18,8 +18,8 @@ A Project Environment Variable resource defines an Environment Variable on a Ver For more detailed information, please see the [Vercel documentation](https://vercel.com/docs/concepts/projects/environment-variables). -~> Terraform currently provides both a standalone Project Environment Variable resource (a single Environment Variable), and a Project resource with Environment Variables defined in-line via the `environment` field. -At this time you cannot use a Vercel Project resource with in-line `environment` in conjunction with any `vercel_project_environment_variable` resources. Doing so will cause a conflict of settings and will overwrite Environment Variables. +~> Terraform currently provides this Project Environment Variable resource (a single Environment Variable), a Project Environment Variables resource (multiple Environment Variables), and a Project resource with Environment Variables defined in-line via the `environment` field. +At this time you cannot use a Vercel Project resource with in-line `environment` in conjunction with any `vercel_project_environment_variables` or `vercel_project_environment_variable` resources. Doing so will cause a conflict of settings and will overwrite Environment Variables. ## Example Usage diff --git a/docs/resources/project_environment_variables.md b/docs/resources/project_environment_variables.md new file mode 100644 index 00000000..3265ad68 --- /dev/null +++ b/docs/resources/project_environment_variables.md @@ -0,0 +1,89 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_project_environment_variables Resource - terraform-provider-vercel" +subcategory: "" +description: |- + Provides a resource for managing a number of Project Environment Variables. + This resource defines multiple Environment Variables on a Vercel Project. + For more detailed information, please see the Vercel documentation https://vercel.com/docs/concepts/projects/environment-variables. + ~> Terraform currently provides this Project Environment Variables resource (multiple Environment Variables), a single Project Environment Variable Resource, and a Project resource with Environment Variables defined in-line via the environment field. + At this time you cannot use a Vercel Project resource with in-line environment in conjunction with any vercel_project_environment_variables or vercel_project_environment_variable resources. Doing so will cause a conflict of settings and will overwrite Environment Variables. +--- + +# vercel_project_environment_variables (Resource) + +Provides a resource for managing a number of Project Environment Variables. + +This resource defines multiple Environment Variables on a Vercel Project. + +For more detailed information, please see the [Vercel documentation](https://vercel.com/docs/concepts/projects/environment-variables). + +~> Terraform currently provides this Project Environment Variables resource (multiple Environment Variables), a single Project Environment Variable Resource, and a Project resource with Environment Variables defined in-line via the `environment` field. +At this time you cannot use a Vercel Project resource with in-line `environment` in conjunction with any `vercel_project_environment_variables` or `vercel_project_environment_variable` resources. Doing so will cause a conflict of settings and will overwrite Environment Variables. + +## Example Usage + +```terraform +resource "vercel_project" "example" { + name = "example-project" + + git_repository = { + type = "github" + repo = "vercel/some-repo" + } +} + +resource "vercel_project_environment_variables" "example" { + project_id = vercel_project.test.id + variables = [ + { + key = "SOME_VARIABLE" + value = "some_value" + target = ["production", "preview"] + }, + { + key = "ANOTHER_VARIABLE" + value = "another_value" + git_branch = "staging" + target = ["preview"] + }, + { + key = "SENSITIVE_VARIABLE" + value = "sensitive_value" + target = ["production"] + sensitive = true + } + ] +} +``` + + +## Schema + +### Required + +- `project_id` (String) The ID of the Vercel project. +- `variables` (Attributes Set) A set of Environment Variables that should be configured for the project. (see [below for nested schema](#nestedatt--variables)) + +### Optional + +- `team_id` (String) The ID of the Vercel team. Required when configuring a team resource if a default team has not been set in the provider. + + +### Nested Schema for `variables` + +Required: + +- `key` (String) The name of the Environment Variable. +- `target` (Set of String) The environments that the Environment Variable should be present on. Valid targets are either `production`, `preview`, or `development`. +- `value` (String) The value of the Environment Variable. + +Optional: + +- `comment` (String) A comment explaining what the environment variable is for. +- `git_branch` (String) The git branch of the Environment Variable. +- `sensitive` (Boolean) Whether the Environment Variable is sensitive or not. + +Read-Only: + +- `id` (String) The ID of the Environment Variable. diff --git a/examples/resources/vercel_project_environment_variables/resource.tf b/examples/resources/vercel_project_environment_variables/resource.tf new file mode 100644 index 00000000..3c614c52 --- /dev/null +++ b/examples/resources/vercel_project_environment_variables/resource.tf @@ -0,0 +1,31 @@ +resource "vercel_project" "example" { + name = "example-project" + + git_repository = { + type = "github" + repo = "vercel/some-repo" + } +} + +resource "vercel_project_environment_variables" "example" { + project_id = vercel_project.test.id + variables = [ + { + key = "SOME_VARIABLE" + value = "some_value" + target = ["production", "preview"] + }, + { + key = "ANOTHER_VARIABLE" + value = "another_value" + git_branch = "staging" + target = ["preview"] + }, + { + key = "SENSITIVE_VARIABLE" + value = "sensitive_value" + target = ["production"] + sensitive = true + } + ] +} diff --git a/vercel/provider.go b/vercel/provider.go index 315374c5..18db6be5 100644 --- a/vercel/provider.go +++ b/vercel/provider.go @@ -63,6 +63,7 @@ func (p *vercelProvider) Resources(_ context.Context) []func() resource.Resource newProjectDeploymentRetentionResource, newProjectDomainResource, newProjectEnvironmentVariableResource, + newProjectEnvironmentVariablesResource, newProjectResource, newSharedEnvironmentVariableResource, newTeamConfigResource, diff --git a/vercel/resource_project.go b/vercel/resource_project.go index 2b8b7213..4dda97cc 100644 --- a/vercel/resource_project.go +++ b/vercel/resource_project.go @@ -1753,7 +1753,7 @@ func (r *projectResource) Update(ctx context.Context, req resource.UpdateRequest } if items != nil { - err = r.client.CreateEnvironmentVariables( + _, err = r.client.CreateEnvironmentVariables( ctx, client.CreateEnvironmentVariablesRequest{ ProjectID: plan.ID.ValueString(), diff --git a/vercel/resource_project_environment_variable_test.go b/vercel/resource_project_environment_variable_test.go index ad836641..48661c4a 100644 --- a/vercel/resource_project_environment_variable_test.go +++ b/vercel/resource_project_environment_variable_test.go @@ -26,7 +26,7 @@ func testAccProjectEnvironmentVariableExists(n, teamID string) resource.TestChec } } -func testAccProjectEnvironmentVariablesDoNotExist(n, teamID string) resource.TestCheckFunc { +func testAccProjectEnvironmentVariableDoNotExist(n, teamID string) resource.TestCheckFunc { return func(s *terraform.State) error { rs, ok := s.RootModule().Resources[n] if !ok { @@ -50,7 +50,7 @@ func testAccProjectEnvironmentVariablesDoNotExist(n, teamID string) resource.Tes } } -func TestAcc_ProjectEnvironmentVariables(t *testing.T) { +func TestAcc_ProjectEnvironmentVariable(t *testing.T) { nameSuffix := acctest.RandString(16) resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -60,7 +60,7 @@ func TestAcc_ProjectEnvironmentVariables(t *testing.T) { ), Steps: []resource.TestStep{ { - Config: testAccProjectEnvironmentVariablesConfig(nameSuffix), + Config: testAccProjectEnvironmentVariableConfig(nameSuffix), Check: resource.ComposeAggregateTestCheckFunc( testAccProjectEnvironmentVariableExists("vercel_project_environment_variable.example", testTeam()), resource.TestCheckResourceAttr("vercel_project_environment_variable.example", "key", "foo"), @@ -88,7 +88,7 @@ func TestAcc_ProjectEnvironmentVariables(t *testing.T) { ), }, { - Config: testAccProjectEnvironmentVariablesConfigUpdated(nameSuffix), + Config: testAccProjectEnvironmentVariableConfigUpdated(nameSuffix), Check: resource.ComposeAggregateTestCheckFunc( testAccProjectEnvironmentVariableExists("vercel_project_environment_variable.example", testTeam()), resource.TestCheckResourceAttr("vercel_project_environment_variable.example", "key", "foo"), @@ -123,9 +123,9 @@ func TestAcc_ProjectEnvironmentVariables(t *testing.T) { ImportStateIdFunc: getProjectEnvironmentVariableImportID("vercel_project_environment_variable.example_git_branch"), }, { - Config: testAccProjectEnvironmentVariablesConfigDeleted(nameSuffix), + Config: testAccProjectEnvironmentVariableConfigDeleted(nameSuffix), Check: resource.ComposeAggregateTestCheckFunc( - testAccProjectEnvironmentVariablesDoNotExist("vercel_project.example", testTeam()), + testAccProjectEnvironmentVariableDoNotExist("vercel_project.example", testTeam()), ), }, }, @@ -150,7 +150,7 @@ func getProjectEnvironmentVariableImportID(n string) resource.ImportStateIdFunc } } -func testAccProjectEnvironmentVariablesConfig(projectName string) string { +func testAccProjectEnvironmentVariableConfig(projectName string) string { return fmt.Sprintf(` resource "vercel_project" "example" { name = "test-acc-example-project-%[1]s" @@ -200,7 +200,7 @@ resource "vercel_project_environment_variable" "example_not_sensitive" { `, projectName, testGithubRepo(), teamIDConfig()) } -func testAccProjectEnvironmentVariablesConfigUpdated(projectName string) string { +func testAccProjectEnvironmentVariableConfigUpdated(projectName string) string { return fmt.Sprintf(` resource "vercel_project" "example" { name = "test-acc-example-project-%[1]s" @@ -241,7 +241,7 @@ resource "vercel_project_environment_variable" "example_sensitive" { `, projectName, testGithubRepo(), teamIDConfig()) } -func testAccProjectEnvironmentVariablesConfigDeleted(projectName string) string { +func testAccProjectEnvironmentVariableConfigDeleted(projectName string) string { return fmt.Sprintf(` resource "vercel_project" "example" { name = "test-acc-example-project-%[1]s" diff --git a/vercel/resource_project_environment_variables.go b/vercel/resource_project_environment_variables.go index 7612be8d..015ee57e 100644 --- a/vercel/resource_project_environment_variables.go +++ b/vercel/resource_project_environment_variables.go @@ -6,6 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" @@ -96,7 +97,7 @@ At this time you cannot use a Vercel Project resource with in-line ` + "`environ "value": schema.StringAttribute{ Required: true, Description: "The value of the Environment Variable.", - Sensitive: true, + // Sensitive: true, }, "target": schema.SetAttribute{ Required: true, @@ -203,7 +204,7 @@ func (r *projectEnvironmentVariablesResource) ModifyPlan(ctx context.Context, re team, err := r.client.Team(ctx, config.TeamID.ValueString()) if err != nil { resp.Diagnostics.AddError( - "Error validating project environment variable", + "Error validating project environment variables", "Could not validate project environment variable, unexpected error: "+err.Error(), ) return @@ -223,84 +224,98 @@ func (r *projectEnvironmentVariablesResource) ModifyPlan(ctx context.Context, re } } -func (e *ProjectEnvironmentVariables) toCreateEnvironmentVariableRequest() client.CreateEnvironmentVariableRequest { - target := []string{} - for _, t := range e.Target { - target = append(target, t.ValueString()) - } - var envVariableType string - - if e.Sensitive.ValueBool() { - envVariableType = "sensitive" - } else { - envVariableType = "encrypted" +func (e *ProjectEnvironmentVariables) toCreateEnvironmentVariablesRequest(ctx context.Context) (r client.CreateEnvironmentVariablesRequest, err error) { + envs, err := e.environment(ctx) + if err != nil { + return r, err } - return client.CreateEnvironmentVariableRequest{ - EnvironmentVariable: client.EnvironmentVariableRequest{ + variables := []client.EnvironmentVariableRequest{} + for _, e := range envs { + target := []string{} + for _, t := range e.Target { + target = append(target, t.ValueString()) + } + var envVariableType string + if e.Sensitive.ValueBool() { + envVariableType = "sensitive" + } else { + envVariableType = "encrypted" + } + variables = append(variables, client.EnvironmentVariableRequest{ Key: e.Key.ValueString(), Value: e.Value.ValueString(), Target: target, - GitBranch: e.GitBranch.ValueStringPointer(), Type: envVariableType, + GitBranch: e.GitBranch.ValueStringPointer(), Comment: e.Comment.ValueString(), - }, - ProjectID: e.ProjectID.ValueString(), - TeamID: e.TeamID.ValueString(), - } -} - -func (e *ProjectEnvironmentVariables) toUpdateEnvironmentVariableRequest() client.UpdateEnvironmentVariableRequest { - target := []string{} - for _, t := range e.Target { - target = append(target, t.ValueString()) - } - - var envVariableType string - - if e.Sensitive.ValueBool() { - envVariableType = "sensitive" - } else { - envVariableType = "encrypted" + }) } - return client.UpdateEnvironmentVariableRequest{ - Value: e.Value.ValueString(), - Target: target, - GitBranch: e.GitBranch.ValueStringPointer(), - Type: envVariableType, - ProjectID: e.ProjectID.ValueString(), - TeamID: e.TeamID.ValueString(), - EnvID: e.ID.ValueString(), - Comment: e.Comment.ValueString(), - } + return client.CreateEnvironmentVariablesRequest{ + ProjectID: e.ProjectID.ValueString(), + TeamID: e.TeamID.ValueString(), + EnvironmentVariables: variables, + }, nil } // convertResponseToProjectEnvironmentVariables is used to populate terraform state based on an API response. // Where possible, values from the API response are used to populate state. If not possible, // values from plan are used. -func convertResponseToProjectEnvironmentVariables(response client.EnvironmentVariable, projectID types.String, v types.String) ProjectEnvironmentVariables { - target := []types.String{} - for _, t := range response.Target { - target = append(target, types.StringValue(t)) +func convertResponseToProjectEnvironmentVariables(ctx context.Context, response []client.EnvironmentVariable, plan ProjectEnvironmentVariables) (ProjectEnvironmentVariables, error) { + environment, err := plan.environment(ctx) + if err != nil { + return ProjectEnvironmentVariables{}, fmt.Errorf("error reading project environment variables: %s", err) } - value := types.StringValue(response.Value) - if response.Type == "sensitive" { - value = v + var env []attr.Value + for _, e := range response { + target := []attr.Value{} + for _, t := range e.Target { + target = append(target, types.StringValue(t)) + } + value := types.StringValue(e.Value) + if e.Type == "sensitive" { + value = types.StringNull() + } + if !e.Decrypted || e.Type == "sensitive" { + for _, p := range environment { + if p.Key.ValueString() == e.Key && hasSameTarget(p, e.Target) { + value = p.Value + break + } + } + } + + env = append(env, types.ObjectValueMust( + map[string]attr.Type{ + "key": types.StringType, + "value": types.StringType, + "target": types.SetType{ + ElemType: types.StringType, + }, + "git_branch": types.StringType, + "id": types.StringType, + "sensitive": types.BoolType, + "comment": types.StringType, + }, + map[string]attr.Value{ + "key": types.StringValue(e.Key), + "value": value, + "target": types.SetValueMust(types.StringType, target), + "git_branch": types.StringPointerValue(e.GitBranch), + "id": types.StringValue(e.ID), + "sensitive": types.BoolValue(e.Type == "sensitive"), + "comment": types.StringValue(e.Comment), + }, + )) } return ProjectEnvironmentVariables{ - Target: target, - GitBranch: types.StringPointerValue(response.GitBranch), - Key: types.StringValue(response.Key), - Value: value, - TeamID: toTeamID(response.TeamID), - ProjectID: projectID, - ID: types.StringValue(response.ID), - Sensitive: types.BoolValue(response.Type == "sensitive"), - Comment: types.StringValue(response.Comment), - } + TeamID: toTeamID(plan.TeamID.ValueString()), + ProjectID: plan.ProjectID, + Variables: types.SetValueMust(envVariableElemType, env), + }, nil } // Create will create a new project environment variable for a Vercel project. @@ -316,27 +331,41 @@ func (r *projectEnvironmentVariablesResource) Create(ctx context.Context, req re _, err := r.client.GetProject(ctx, plan.ProjectID.ValueString(), plan.TeamID.ValueString()) if client.NotFound(err) { resp.Diagnostics.AddError( - "Error creating project environment variable", + "Error creating project environment variables", "Could not find project, please make sure both the project_id and team_id match the project and team you wish to deploy to.", ) return } - response, err := r.client.CreateEnvironmentVariable(ctx, plan.toCreateEnvironmentVariableRequest()) + request, err := plan.toCreateEnvironmentVariablesRequest(ctx) if err != nil { resp.Diagnostics.AddError( - "Error creating project environment variable", - "Could not create project environment variable, unexpected error: "+err.Error(), + "Error creating project environment variables", + "Could not create project environment variables request, unexpected error: "+err.Error(), ) return } + created, err := r.client.CreateEnvironmentVariables(ctx, request) + if err != nil { + resp.Diagnostics.AddError( + "Error creating project environment variables", + "Could not create project environment variables, unexpected error: "+err.Error(), + ) + } - result := convertResponseToProjectEnvironmentVariables(response, plan.ProjectID, plan.Value) + result, err := convertResponseToProjectEnvironmentVariables(ctx, created, plan) + if err != nil { + resp.Diagnostics.AddError( + "Error parsing project environment variables", + "Could not read environment variables, unexpected error: "+err.Error(), + ) + return + } - tflog.Info(ctx, "created project environment variable", map[string]interface{}{ - "id": result.ID.ValueString(), + tflog.Info(ctx, "created project environment variables", map[string]interface{}{ "team_id": result.TeamID.ValueString(), "project_id": result.ProjectID.ValueString(), + "variables": created, }) diags = resp.State.Set(ctx, result) @@ -356,27 +385,55 @@ func (r *projectEnvironmentVariablesResource) Read(ctx context.Context, req reso return } - out, err := r.client.GetEnvironmentVariable(ctx, state.ProjectID.ValueString(), state.TeamID.ValueString(), state.ID.ValueString()) + existing, err := state.environment(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Error parsing project environment variables", + "Could not read environment variables, unexpected error: "+err.Error(), + ) + return + } + existingIDs := map[string]struct{}{} + for _, e := range existing { + if e.ID.ValueString() == "" { + existingIDs[e.ID.ValueString()] = struct{}{} + } + } + if len(existingIDs) == 0 { + // no existing environment variables, nothing to do + return + } + + envs, err := r.client.ListEnvironmentVariables(ctx, state.TeamID.ValueString(), state.ProjectID.ValueString()) if client.NotFound(err) { resp.State.RemoveResource(ctx) return } if err != nil { resp.Diagnostics.AddError( - "Error reading project environment variable", - fmt.Sprintf("Could not get project environment variable %s %s %s, unexpected error: %s", - state.ID.ValueString(), - state.ProjectID.ValueString(), - state.TeamID.ValueString(), - err, - ), + "Error reading project environment variables", + "Could not read environment variables, unexpected error: "+err.Error(), ) return } - result := convertResponseToProjectEnvironmentVariables(out, state.ProjectID, state.Value) - tflog.Info(ctx, "read project environment variable", map[string]interface{}{ - "id": result.ID.ValueString(), + var toUse []client.EnvironmentVariable + for _, e := range envs { + if _, ok := existingIDs[e.ID]; ok { + toUse = append(toUse, e) + } + } + + result, err := convertResponseToProjectEnvironmentVariables(ctx, toUse, state) + if err != nil { + resp.Diagnostics.AddError( + "Error parsing project environment variables", + "Could not read environment variables, unexpected error: "+err.Error(), + ) + return + } + + tflog.Info(ctx, "read project environment variables", map[string]interface{}{ "team_id": result.TeamID.ValueString(), "project_id": result.ProjectID.ValueString(), }) @@ -397,19 +454,91 @@ func (r *projectEnvironmentVariablesResource) Update(ctx context.Context, req re return } - response, err := r.client.UpdateEnvironmentVariable(ctx, plan.toUpdateEnvironmentVariableRequest()) + var state ProjectEnvironmentVariables + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + stateEnvs, err := state.environment(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Error parsing project environment variables", + "Could not read environment variables, unexpected error: "+err.Error(), + ) + return + } + planEnvs, err := plan.environment(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Error parsing project environment variables", + "Could not read environment variables, unexpected error: "+err.Error(), + ) + return + } + plannedIDs := map[string]struct{}{} + for _, e := range planEnvs { + if e.ID.ValueString() == "" { + plannedIDs[e.ID.ValueString()] = struct{}{} + } + } + + var toRemove []EnvironmentItem + for _, e := range stateEnvs { + if _, ok := plannedIDs[e.ID.ValueString()]; !ok { + toRemove = append(toRemove, e) + } + } + + for _, v := range toRemove { + err := r.client.DeleteEnvironmentVariable(ctx, state.ProjectID.ValueString(), state.TeamID.ValueString(), v.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Error updating Project Environment Variables", + fmt.Sprintf( + "Could not remove environment variable %s (%s), unexpected error: %s", + v.Key.ValueString(), + v.ID.ValueString(), + err, + ), + ) + return + } + tflog.Info(ctx, "deleted environment variable", map[string]interface{}{ + "team_id": plan.TeamID.ValueString(), + "project_id": plan.ProjectID.ValueString(), + "environment_id": v.ID.ValueString(), + }) + } + + request, err := plan.toCreateEnvironmentVariablesRequest(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Error creating project environment variables", + "Could not create project environment variables request, unexpected error: "+err.Error(), + ) + return + } + response, err := r.client.CreateEnvironmentVariables(ctx, request) if err != nil { resp.Diagnostics.AddError( - "Error updating project environment variable", + "Error updating project environment variables", "Could not update project environment variable, unexpected error: "+err.Error(), ) return } - result := convertResponseToProjectEnvironmentVariables(response, plan.ProjectID, plan.Value) + result, err := convertResponseToProjectEnvironmentVariables(ctx, response, plan) + if err != nil { + resp.Diagnostics.AddError( + "Error parsing project environment variables", + "Could not read environment variables, unexpected error: "+err.Error(), + ) + return + } - tflog.Info(ctx, "updated project environment variable", map[string]interface{}{ - "id": result.ID.ValueString(), + tflog.Info(ctx, "updated project environment variables", map[string]interface{}{ "team_id": result.TeamID.ValueString(), "project_id": result.ProjectID.ValueString(), }) @@ -430,25 +559,32 @@ func (r *projectEnvironmentVariablesResource) Delete(ctx context.Context, req re return } - err := r.client.DeleteEnvironmentVariable(ctx, state.ProjectID.ValueString(), state.TeamID.ValueString(), state.ID.ValueString()) - if client.NotFound(err) { - return - } + envs, err := state.environment(ctx) if err != nil { resp.Diagnostics.AddError( - "Error deleting project environment variable", - fmt.Sprintf( - "Could not delete project environment variable %s, unexpected error: %s", - state.ID.ValueString(), - err, - ), + "Error parsing project environment variables", + "Could not read environment variables, unexpected error: "+err.Error(), ) return } - - tflog.Info(ctx, "deleted project environment variable", map[string]interface{}{ - "id": state.ID.ValueString(), - "team_id": state.TeamID.ValueString(), - "project_id": state.ProjectID.ValueString(), - }) + for _, v := range envs { + err := r.client.DeleteEnvironmentVariable(ctx, state.ProjectID.ValueString(), state.TeamID.ValueString(), v.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Error updating Project Environment Variables", + fmt.Sprintf( + "Could not remove environment variable %s (%s), unexpected error: %s", + v.Key.ValueString(), + v.ID.ValueString(), + err, + ), + ) + return + } + tflog.Info(ctx, "deleted environment variable", map[string]interface{}{ + "team_id": state.TeamID.ValueString(), + "project_id": state.ProjectID.ValueString(), + "environment_id": v.ID.ValueString(), + }) + } } diff --git a/vercel/resource_project_environment_variables_test.go b/vercel/resource_project_environment_variables_test.go new file mode 100644 index 00000000..752434d3 --- /dev/null +++ b/vercel/resource_project_environment_variables_test.go @@ -0,0 +1,131 @@ +package vercel_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAcc_ProjectEnvironmentVariables(t *testing.T) { + projectName := "test-acc-example-env-vars-" + acctest.RandString(16) + resourceName := "vercel_project_environment_variables.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: resource.ComposeAggregateTestCheckFunc( + testAccProjectDestroy("vercel_project.test", testTeam()), + ), + Steps: []resource.TestStep{ + { + Config: testAccProjectEnvironmentVariablesConfig(projectName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "variables.#", "2"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "variables.*", map[string]string{ + "key": "TEST_VAR_1", + "value": "test_value_1", + }), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "variables.*", map[string]string{ + "key": "TEST_VAR_2", + "value": "test_value_2", + "git_branch": "staging", + }), + resource.TestCheckResourceAttrSet(resourceName, "variables.0.id"), + resource.TestCheckResourceAttrSet(resourceName, "variables.1.id"), + ), + }, + { + Config: testAccProjectEnvironmentVariablesConfigUpdated(projectName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "variables.#", "3"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "variables.*", map[string]string{ + "key": "TEST_VAR_2", + "value": "test_value_2_updated", + }), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "variables.*", map[string]string{ + "key": "TEST_VAR_3", + "value": "test_value_3", + }), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "variables.*", map[string]string{ + "key": "TEST_VAR_4", + "value": "sensitive_value", + "sensitive": "true", + }), + resource.TestCheckResourceAttrSet(resourceName, "variables.0.id"), + resource.TestCheckResourceAttrSet(resourceName, "variables.1.id"), + resource.TestCheckResourceAttrSet(resourceName, "variables.2.id"), + ), + }, + }, + }) +} + +func testAccProjectEnvironmentVariablesConfig(projectName string) string { + return fmt.Sprintf(` +resource "vercel_project" "test" { + name = "%s" + %[2]s + + git_repository = { + type = "github" + repo = "%[3]s" + } +} + +resource "vercel_project_environment_variables" "test" { + project_id = vercel_project.test.id + %[2]s + variables = [{ + key = "TEST_VAR_1" + value = "test_value_1" + target = ["production", "preview"] + }, + { + key = "TEST_VAR_2" + value = "test_value_2" + git_branch = "staging" + target = ["preview"] + } + ] +} +`, projectName, teamIDConfig(), testGithubRepo()) +} + +func testAccProjectEnvironmentVariablesConfigUpdated(projectName string) string { + return fmt.Sprintf(` +resource "vercel_project" "test" { + name = "%s" + %[2]s + + git_repository = { + type = "github" + repo = "%[3]s" + } +} + +resource "vercel_project_environment_variables" "test" { + project_id = vercel_project.test.id + %[2]s + variables = [ + { + key = "TEST_VAR_2" + value = "test_value_2_updated" + target = ["preview", "development"] + }, + { + key = "TEST_VAR_3" + value = "test_value_3" + target = ["production"] + }, + { + key = "TEST_VAR_4" + value = "sensitive_value" + target = ["production"] + sensitive = true + } + ] +} +`, projectName, teamIDConfig(), testGithubRepo()) +}