diff --git a/README.md b/README.md index 8b5e63cd..efb8fe49 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ## Requirements - [Terraform](https://www.terraform.io/downloads.html) 1.1 or higher -- [Go](https://golang.org/doc/install) 1.17 (to build the provider plugin) +- [Go](https://golang.org/doc/install) 1.19 (to build the provider plugin) - [Task](https://taskfile.dev) v3 (to run Taskfile commands) ## Building The Provider @@ -19,7 +19,7 @@ $ task build ## Developing the Provider -If you wish to work on the provider, you'll first need [Go](http://www.golang.org) installed on your machine (version 1.17+ is _required_). +If you wish to work on the provider, you'll first need [Go](http://www.golang.org) installed on your machine (version 1.19+ is _required_). To compile the provider, run `task build`. This will build the provider and put the provider binary in the repository root. @@ -41,7 +41,6 @@ The acceptance tests require a few environment variables to be set: * `VERCEL_API_TOKEN` - this can be generated [here](https://vercel.com/account/tokens) * `VERCEL_TERRAFORM_TESTING_TEAM` - a Vercel team_id where resources can be created and destroyed * `VERCEL_TERRAFORM_TESTING_GITHUB_REPO` - a GitHub repository in the form 'org/repo' that can be used to trigger deployments -* `VERCEL_TERRAFORM_TESTING_GITLAB_REPO` - a GitLab repository in the form 'namespace/repo' that can be used to trigger deployments * `VERCEL_TERRAFORM_TESTING_BITBUCKET_REPO` - a Bitbucket repository in the form 'project/repo' that can be used to trigger deployments ```sh @@ -51,7 +50,7 @@ $ task test In order to run the tests with extra debugging context, prefix with `TF_LOG` (see the [terraform documentation](https://www.terraform.io/docs/internals/debugging.html) for details). ```sh -$ TF_LOG=trace task test +$ TF_LOG=INFO task test ``` To run a specific set of tests, use the `-run` flag and specify a regex pattern matching the test names. diff --git a/Taskfile.yml b/Taskfile.yml index 1a3b249d..a40e2e44 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -24,6 +24,8 @@ tasks: desc: "Install the tfplugindocs tool" cmds: - go install github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs@v0.7.0 + status: + - which staticcheck docs: desc: "Update the docs generated from description fields" @@ -37,6 +39,7 @@ tasks: - "vercel/**/*.go" - "main.go" - "examples/**/*.tf" + - "examples/**/*.sh" generates: - docs/**/*.md diff --git a/client/deployment_create.go b/client/deployment_create.go index 143e913b..ad749e56 100644 --- a/client/deployment_create.go +++ b/client/deployment_create.go @@ -150,7 +150,7 @@ func (e MissingFilesError) Error() string { } func (c *Client) getGitSource(ctx context.Context, projectID, ref, teamID string) (gs gitSource, err error) { - project, err := c.GetProject(ctx, projectID, teamID) + project, err := c.GetProject(ctx, projectID, teamID, false) if err != nil { return gs, fmt.Errorf("error getting project: %w", err) } diff --git a/client/environment_variable_create.go b/client/environment_variable_create.go new file mode 100644 index 00000000..944c28d1 --- /dev/null +++ b/client/environment_variable_create.go @@ -0,0 +1,49 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// CreateEnvironmentVariableRequest defines the information that needs to be passed to Vercel in order to +// create an environment variable. +type CreateEnvironmentVariableRequest struct { + Key string `json:"key"` + Value string `json:"value"` + Target []string `json:"target"` + GitBranch *string `json:"gitBranch,omitempty"` + Type string `json:"type"` + ProjectID string `json:"-"` + TeamID string `json:"-"` +} + +// CreateEnvironmentVariable will create a brand new environment variable if one does not exist. +func (c *Client) CreateEnvironmentVariable(ctx context.Context, request CreateEnvironmentVariableRequest) (e EnvironmentVariable, err error) { + url := fmt.Sprintf("%s/v9/projects/%s/env", c.baseURL, request.ProjectID) + if request.TeamID != "" { + url = fmt.Sprintf("%s?teamId=%s", url, request.TeamID) + } + payload := string(mustMarshal(request)) + req, err := http.NewRequestWithContext( + ctx, + "POST", + url, + strings.NewReader(payload), + ) + if err != nil { + return e, err + } + + tflog.Trace(ctx, "creating environment variable", map[string]interface{}{ + "url": url, + "payload": payload, + }) + err = c.doRequest(req, &e) + // The API response returns an encrypted environment variable, but we want to return the decrypted version. + e.Value = request.Value + return e, err +} diff --git a/client/environment_variable_update.go b/client/environment_variable_update.go new file mode 100644 index 00000000..e40a5603 --- /dev/null +++ b/client/environment_variable_update.go @@ -0,0 +1,50 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// UpdateEnvironmentVariableRequest defines the information that needs to be passed to Vercel in order to +// update an environment variable. +type UpdateEnvironmentVariableRequest struct { + Key string `json:"key"` + Value string `json:"value"` + Target []string `json:"target"` + GitBranch *string `json:"gitBranch,omitempty"` + Type string `json:"type"` + ProjectID string `json:"-"` + TeamID string `json:"-"` + EnvID string `json:"-"` +} + +// UpdateEnvironmentVariable will update an existing environment variable to the latest information. +func (c *Client) UpdateEnvironmentVariable(ctx context.Context, request UpdateEnvironmentVariableRequest) (e EnvironmentVariable, err error) { + url := fmt.Sprintf("%s/v9/projects/%s/env/%s", c.baseURL, request.ProjectID, request.EnvID) + if request.TeamID != "" { + url = fmt.Sprintf("%s?teamId=%s", url, request.TeamID) + } + payload := string(mustMarshal(request)) + req, err := http.NewRequestWithContext( + ctx, + "PATCH", + url, + strings.NewReader(payload), + ) + if err != nil { + return e, err + } + + tflog.Trace(ctx, "updating environment variable", map[string]interface{}{ + "url": url, + "payload": payload, + }) + err = c.doRequest(req, &e) + // The API response returns an encrypted environment variable, but we want to return the decrypted version. + e.Value = request.Value + return e, err +} diff --git a/client/environment_variable_upsert.go b/client/environment_variable_upsert.go deleted file mode 100644 index 77a028f2..00000000 --- a/client/environment_variable_upsert.go +++ /dev/null @@ -1,39 +0,0 @@ -package client - -import ( - "context" - "fmt" - "net/http" - "strings" - - "github.com/hashicorp/terraform-plugin-log/tflog" -) - -// UpsertEnvironmentVariableRequest defines the information that needs to be passed to Vercel in order to -// create or update an environment variable. -type UpsertEnvironmentVariableRequest EnvironmentVariable - -// UpsertEnvironmentVariable will either create a brand new environment variable if one does not exist, or will -// update an existing environment variable to the latest information. -func (c *Client) UpsertEnvironmentVariable(ctx context.Context, projectID, teamID string, request UpsertEnvironmentVariableRequest) error { - url := fmt.Sprintf("%s/v8/projects/%s/env", c.baseURL, projectID) - if teamID != "" { - url = fmt.Sprintf("%s?teamId=%s", url, teamID) - } - payload := string(mustMarshal(request)) - req, err := http.NewRequestWithContext( - ctx, - "POST", - url, - strings.NewReader(payload), - ) - if err != nil { - return err - } - - tflog.Trace(ctx, "upserting environment variable", map[string]interface{}{ - "url": url, - "payload": payload, - }) - return c.doRequest(req, nil) -} diff --git a/client/environment_variables_get.go b/client/environment_variables_get.go index c082b0ad..858a8cf1 100644 --- a/client/environment_variables_get.go +++ b/client/environment_variables_get.go @@ -26,9 +26,31 @@ func (c *Client) getEnvironmentVariables(ctx context.Context, projectID, teamID envResponse := struct { Env []EnvironmentVariable `json:"envs"` }{} - tflog.Trace(ctx, "getting environment variable", map[string]interface{}{ + tflog.Trace(ctx, "getting environment variables", map[string]interface{}{ "url": url, }) err = c.doRequest(req, &envResponse) return envResponse.Env, err } + +func (c *Client) GetEnvironmentVariable(ctx context.Context, projectID, teamID, envID string) (e EnvironmentVariable, err error) { + url := fmt.Sprintf("%s/v1/projects/%s/env/%s", c.baseURL, projectID, envID) + if teamID != "" { + url = fmt.Sprintf("%s?teamId=%s", url, teamID) + } + req, err := http.NewRequestWithContext( + ctx, + "GET", + url, + nil, + ) + if err != nil { + return e, err + } + + tflog.Trace(ctx, "getting environment variable", map[string]interface{}{ + "url": url, + }) + err = c.doRequest(req, &e) + return e, err +} diff --git a/client/project_get.go b/client/project_get.go index 5e08c7f1..bb909912 100644 --- a/client/project_get.go +++ b/client/project_get.go @@ -70,7 +70,7 @@ type ProjectResponse struct { } // GetProject retrieves information about an existing project from Vercel. -func (c *Client) GetProject(ctx context.Context, projectID, teamID string) (r ProjectResponse, err error) { +func (c *Client) GetProject(ctx context.Context, projectID, teamID string, shouldFetchEnvironmentVariables bool) (r ProjectResponse, err error) { url := fmt.Sprintf("%s/v8/projects/%s", c.baseURL, projectID) if teamID != "" { url = fmt.Sprintf("%s?teamId=%s", url, teamID) @@ -85,17 +85,23 @@ func (c *Client) GetProject(ctx context.Context, projectID, teamID string) (r Pr return r, err } tflog.Trace(ctx, "getting project", map[string]interface{}{ - "url": url, + "url": url, + "shouldFetchEnvironment": shouldFetchEnvironmentVariables, }) err = c.doRequest(req, &r) if err != nil { - return r, err + return r, fmt.Errorf("unable to get project: %w", err) } - env, err := c.getEnvironmentVariables(ctx, projectID, teamID) - if err != nil { - return r, fmt.Errorf("error getting environment variables for project: %w", err) + if shouldFetchEnvironmentVariables { + r.EnvironmentVariables, err = c.getEnvironmentVariables(ctx, projectID, teamID) + if err != nil { + return r, fmt.Errorf("error getting environment variables for project: %w", err) + } + } else { + // The get project endpoint returns environment variables, but returns them fully + // encrypted. This isn't useful, so we just remove them. + r.EnvironmentVariables = nil } - r.EnvironmentVariables = env return r, err } diff --git a/docs/resources/project.md b/docs/resources/project.md index a87e2308..f04a624e 100644 --- a/docs/resources/project.md +++ b/docs/resources/project.md @@ -6,6 +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. --- # vercel_project (Resource) @@ -16,6 +18,9 @@ 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. + ## Example Usage ```terraform @@ -26,14 +31,6 @@ resource "vercel_project" "with_git" { name = "example-project-with-git" framework = "nextjs" - environment = [ - { - key = "bar" - value = "baz" - target = ["preview"] - } - ] - git_repository = { type = "github" repo = "vercel/some-repo" @@ -46,14 +43,6 @@ resource "vercel_project" "with_git" { resource "vercel_project" "example" { name = "example-project" framework = "nextjs" - - environment = [ - { - key = "bar" - value = "baz" - target = ["preview", "production"] - } - ] } ``` @@ -68,7 +57,7 @@ resource "vercel_project" "example" { - `build_command` (String) The build command for this project. If omitted, this value will be automatically detected. - `dev_command` (String) The dev command for this project. If omitted, this value will be automatically detected. -- `environment` (Attributes Set) A set of environment variables that should be configured for the project. (see [below for nested schema](#nestedatt--environment)) +- `environment` (Attributes Set) A set of Environment Variables that should be configured for the project. (see [below for nested schema](#nestedatt--environment)) - `framework` (String) The framework that is being used for this project. If omitted, no framework is selected. - `git_repository` (Attributes) The Git Repository that will be connected to the project. When this is defined, any pushes to the specified connected Git Repository will be automatically deployed. This requires the corresponding Vercel for [Github](https://vercel.com/docs/concepts/git/vercel-for-github), [Gitlab](https://vercel.com/docs/concepts/git/vercel-for-gitlab) or [Bitbucket](https://vercel.com/docs/concepts/git/vercel-for-bitbucket) plugins to be installed. (see [below for nested schema](#nestedatt--git_repository)) - `ignore_command` (String) When a commit is pushed to the Git repository that is connected with your Project, its SHA will determine if a new Build has to be issued. If the SHA was deployed before, no new Build will be issued. You can customize this behavior with a command that exits with code 1 (new Build needed) or code 0. @@ -88,11 +77,11 @@ resource "vercel_project" "example" { Optional: -- `git_branch` (String) The git branch of the environment variable. -- `id` (String) The ID of the environment variable -- `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, Sensitive) The value of the environment variable. +- `git_branch` (String) The git branch of the Environment Variable. +- `id` (String) The ID of the Environment Variable. +- `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, Sensitive) The value of the Environment Variable. diff --git a/docs/resources/project_environment_variable.md b/docs/resources/project_environment_variable.md new file mode 100644 index 00000000..f18ee8e0 --- /dev/null +++ b/docs/resources/project_environment_variable.md @@ -0,0 +1,87 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_project_environment_variable Resource - terraform-provider-vercel" +subcategory: "" +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. +--- + +# vercel_project_environment_variable (Resource) + +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. + +## Example Usage + +```terraform +resource "vercel_project" "example" { + name = "example-project" + + git_repository = { + type = "github" + repo = "vercel/some-repo" + } +} + +# An environment variable that will be created +# for this project for the "production" environment. +resource "vercel_project_environment_variable" "example" { + project_id = vercel_project.example.id + key = "foo" + value = "bar" + target = ["production"] +} + +# An environment variable that will be created +# for this project for the "preview" environment when the branch is "staging". +resource "vercel_project_environment_variable" "example_git_branch" { + project_id = vercel_project.example.id + key = "foo" + value = "bar-staging" + target = ["preview"] + git_branch = "staging" +} +``` + + +## Schema + +### Required + +- `key` (String) The name of the Environment Variable. +- `project_id` (String) The ID of the Vercel project. +- `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 + +- `git_branch` (String) The git branch of the Environment Variable. +- `team_id` (String) The ID of the Vercel team. + +### Read-Only + +- `id` (String) The ID of the Environment Variable. + +## Import + +Import is supported using the following syntax: + +```shell +# Import via the team_id, project_id and environment variable id. +# team_id can be found in the team `settings` tab in the Vercel UI. +# environment variable id can be taken from the network tab on the project page. +terraform import vercel_project_environment_variable.example team_xxxxxxxxxxxxxxxxxxxxxxxx/prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx/FdT2e1E5Of6Cihmt + +# If importing without a team, simply use the project_id and environment variable id. +terraform import vercel_project_environment_variable.example_git_branch prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx/FdT2e1E5Of6Cihmt +``` diff --git a/examples/resources/vercel_project/resource.tf b/examples/resources/vercel_project/resource.tf index ac3e1a77..dde97e38 100644 --- a/examples/resources/vercel_project/resource.tf +++ b/examples/resources/vercel_project/resource.tf @@ -5,14 +5,6 @@ resource "vercel_project" "with_git" { name = "example-project-with-git" framework = "nextjs" - environment = [ - { - key = "bar" - value = "baz" - target = ["preview"] - } - ] - git_repository = { type = "github" repo = "vercel/some-repo" @@ -25,12 +17,4 @@ resource "vercel_project" "with_git" { resource "vercel_project" "example" { name = "example-project" framework = "nextjs" - - environment = [ - { - key = "bar" - value = "baz" - target = ["preview", "production"] - } - ] } diff --git a/examples/resources/vercel_project_environment_variable/import.sh b/examples/resources/vercel_project_environment_variable/import.sh new file mode 100644 index 00000000..cc619c73 --- /dev/null +++ b/examples/resources/vercel_project_environment_variable/import.sh @@ -0,0 +1,7 @@ +# Import via the team_id, project_id and environment variable id. +# team_id can be found in the team `settings` tab in the Vercel UI. +# environment variable id can be taken from the network tab on the project page. +terraform import vercel_project_environment_variable.example team_xxxxxxxxxxxxxxxxxxxxxxxx/prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx/FdT2e1E5Of6Cihmt + +# If importing without a team, simply use the project_id and environment variable id. +terraform import vercel_project_environment_variable.example_git_branch prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx/FdT2e1E5Of6Cihmt diff --git a/examples/resources/vercel_project_environment_variable/resource.tf b/examples/resources/vercel_project_environment_variable/resource.tf new file mode 100644 index 00000000..bb85fa1d --- /dev/null +++ b/examples/resources/vercel_project_environment_variable/resource.tf @@ -0,0 +1,27 @@ +resource "vercel_project" "example" { + name = "example-project" + + git_repository = { + type = "github" + repo = "vercel/some-repo" + } +} + +# An environment variable that will be created +# for this project for the "production" environment. +resource "vercel_project_environment_variable" "example" { + project_id = vercel_project.example.id + key = "foo" + value = "bar" + target = ["production"] +} + +# An environment variable that will be created +# for this project for the "preview" environment when the branch is "staging". +resource "vercel_project_environment_variable" "example_git_branch" { + project_id = vercel_project.example.id + key = "foo" + value = "bar-staging" + target = ["preview"] + git_branch = "staging" +} diff --git a/vercel/data_source_project.go b/vercel/data_source_project.go index 2c8a1f1a..42831f9f 100644 --- a/vercel/data_source_project.go +++ b/vercel/data_source_project.go @@ -170,7 +170,7 @@ func (r dataSourceProject) Read(ctx context.Context, req datasource.ReadRequest, return } - out, err := r.p.client.GetProject(ctx, config.Name.Value, config.TeamID.Value) + out, err := r.p.client.GetProject(ctx, config.Name.Value, config.TeamID.Value, true) if err != nil { resp.Diagnostics.AddError( "Error reading project", @@ -183,7 +183,7 @@ func (r dataSourceProject) Read(ctx context.Context, req datasource.ReadRequest, return } - result := convertResponseToProject(out, config.coercedFields()) + result := convertResponseToProject(out, config.coercedFields(), types.Set{Null: true}) tflog.Trace(ctx, "read project", map[string]interface{}{ "team_id": result.TeamID.Value, "project_id": result.ID.Value, diff --git a/vercel/provider.go b/vercel/provider.go index 3dfb3e7e..3fe6fc19 100644 --- a/vercel/provider.go +++ b/vercel/provider.go @@ -45,11 +45,12 @@ Use the navigation to the left to read about the available resources. // GetResources shows the available resources for the vercel provider func (p *vercelProvider) GetResources(_ context.Context) (map[string]provider.ResourceType, diag.Diagnostics) { return map[string]provider.ResourceType{ - "vercel_alias": resourceAliasType{}, - "vercel_deployment": resourceDeploymentType{}, - "vercel_project": resourceProjectType{}, - "vercel_project_domain": resourceProjectDomainType{}, - "vercel_dns_record": resourceDNSRecordType{}, + "vercel_alias": resourceAliasType{}, + "vercel_deployment": resourceDeploymentType{}, + "vercel_project": resourceProjectType{}, + "vercel_project_domain": resourceProjectDomainType{}, + "vercel_project_environment_variable": resourceProjectEnvironmentVariableType{}, + "vercel_dns_record": resourceDNSRecordType{}, }, nil } diff --git a/vercel/resource_deployment.go b/vercel/resource_deployment.go index c01b6d77..82a8ecff 100644 --- a/vercel/resource_deployment.go +++ b/vercel/resource_deployment.go @@ -246,7 +246,7 @@ func (r resourceDeployment) Create(ctx context.Context, req resource.CreateReque Ref: plan.Ref.Value, } - _, err = r.p.client.GetProject(ctx, plan.ProjectID.Value, plan.TeamID.Value) + _, err = r.p.client.GetProject(ctx, plan.ProjectID.Value, plan.TeamID.Value, false) var apiErr client.APIError if err != nil && errors.As(err, &apiErr) && apiErr.StatusCode == 404 { resp.Diagnostics.AddError( diff --git a/vercel/resource_project.go b/vercel/resource_project.go index 126c21fb..a8620a81 100644 --- a/vercel/resource_project.go +++ b/vercel/resource_project.go @@ -27,6 +27,9 @@ 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. `, Attributes: map[string]tfsdk.Attribute{ "team_id": { @@ -89,11 +92,11 @@ For more detailed information, please see the [Vercel documentation](https://ver }, }, "environment": { - Description: "A set of environment variables that should be configured for the project.", + Description: "A set of Environment Variables that should be configured for the project.", Optional: true, Attributes: tfsdk.SetNestedAttributes(map[string]tfsdk.Attribute{ "target": { - Description: "The environments that the environment variable should be present on. Valid targets are either `production`, `preview`, or `development`.", + Description: "The environments that the Environment Variable should be present on. Valid targets are either `production`, `preview`, or `development`.", Type: types.SetType{ ElemType: types.StringType, }, @@ -103,23 +106,23 @@ For more detailed information, please see the [Vercel documentation](https://ver Required: true, }, "git_branch": { - Description: "The git branch of the environment variable.", + Description: "The git branch of the Environment Variable.", Type: types.StringType, Optional: true, }, "key": { - Description: "The name of the environment variable.", + Description: "The name of the Environment Variable.", Type: types.StringType, Required: true, }, "value": { - Description: "The value of the environment variable.", + Description: "The value of the Environment Variable.", Type: types.StringType, Required: true, Sensitive: true, }, "id": { - Description: "The ID of the environment variable", + Description: "The ID of the Environment Variable.", Type: types.StringType, PlanModifiers: tfsdk.AttributePlanModifiers{resource.UseStateForUnknown()}, Computed: true, @@ -214,7 +217,16 @@ func (r resourceProject) Create(ctx context.Context, req resource.CreateRequest, return } - out, err := r.p.client.CreateProject(ctx, plan.TeamID.Value, plan.toCreateProjectRequest()) + environment, 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 + } + + out, err := r.p.client.CreateProject(ctx, plan.TeamID.Value, plan.toCreateProjectRequest(environment)) if err != nil { resp.Diagnostics.AddError( "Error creating project", @@ -223,7 +235,7 @@ func (r resourceProject) Create(ctx context.Context, req resource.CreateRequest, return } - result := convertResponseToProject(out, plan.coercedFields()) + result := convertResponseToProject(out, plan.coercedFields(), plan.Environment) tflog.Trace(ctx, "created project", map[string]interface{}{ "team_id": result.TeamID.Value, "project_id": result.ID.Value, @@ -246,7 +258,7 @@ func (r resourceProject) Read(ctx context.Context, req resource.ReadRequest, res return } - out, err := r.p.client.GetProject(ctx, state.ID.Value, state.TeamID.Value) + out, err := r.p.client.GetProject(ctx, state.ID.Value, state.TeamID.Value, !state.Environment.Null) if client.NotFound(err) { resp.State.RemoveResource(ctx) return @@ -263,7 +275,7 @@ func (r resourceProject) Read(ctx context.Context, req resource.ReadRequest, res return } - result := convertResponseToProject(out, state.coercedFields()) + result := convertResponseToProject(out, state.coercedFields(), state.Environment) tflog.Trace(ctx, "read project", map[string]interface{}{ "team_id": result.TeamID.Value, "project_id": result.ID.Value, @@ -297,9 +309,9 @@ func containsEnvVar(env []EnvironmentItem, v EnvironmentItem) bool { // diffEnvVars is used to determine the set of environment variables that need to be updated, // and the set of environment variables that need to be removed. -func diffEnvVars(oldVars, newVars []EnvironmentItem) (toUpsert, toRemove []EnvironmentItem) { +func diffEnvVars(oldVars, newVars []EnvironmentItem) (toCreate, toRemove []EnvironmentItem) { toRemove = []EnvironmentItem{} - toUpsert = []EnvironmentItem{} + toCreate = []EnvironmentItem{} for _, e := range oldVars { if !containsEnvVar(newVars, e) { toRemove = append(toRemove, e) @@ -307,10 +319,10 @@ func diffEnvVars(oldVars, newVars []EnvironmentItem) (toUpsert, toRemove []Envir } for _, e := range newVars { if !containsEnvVar(oldVars, e) { - toUpsert = append(toUpsert, e) + toCreate = append(toCreate, e) } } - return toUpsert, toRemove + return toCreate, toRemove } // Update will update a project and it's associated environment variables via the vercel API. @@ -332,7 +344,29 @@ func (r resourceProject) Update(ctx context.Context, req resource.UpdateRequest, } /* Update the environment variables first */ - toUpsert, toRemove := diffEnvVars(state.Environment, plan.Environment) + 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 + } + stateEnvs, err := state.environment(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Error parsing project environment variables from state", + "Could not read environment variables, unexpected error: "+err.Error(), + ) + return + } + + tflog.Error(ctx, "planEnvs", map[string]interface{}{ + "plan_envs": planEnvs, + "state_envs": stateEnvs, + }) + + toCreate, toRemove := diffEnvVars(stateEnvs, planEnvs) for _, v := range toRemove { err := r.p.client.DeleteEnvironmentVariable(ctx, state.ID.Value, state.TeamID.Value, v.ID.Value) if err != nil { @@ -353,12 +387,10 @@ func (r resourceProject) Update(ctx context.Context, req resource.UpdateRequest, "environment_id": v.ID.Value, }) } - for _, v := range toUpsert { - err := r.p.client.UpsertEnvironmentVariable( + for _, v := range toCreate { + result, err := r.p.client.CreateEnvironmentVariable( ctx, - state.ID.Value, - state.TeamID.Value, - v.toUpsertEnvironmentVariableRequest(), + v.toCreateEnvironmentVariableRequest(plan.ID.Value, plan.TeamID.Value), ) if err != nil { resp.Diagnostics.AddError( @@ -374,7 +406,7 @@ func (r resourceProject) Update(ctx context.Context, req resource.UpdateRequest, tflog.Trace(ctx, "upserted environment variable", map[string]interface{}{ "team_id": plan.TeamID.Value, "project_id": plan.ID.Value, - "environment_id": v.ID.Value, + "environment_id": result.ID, }) } @@ -392,7 +424,7 @@ func (r resourceProject) Update(ctx context.Context, req resource.UpdateRequest, return } - result := convertResponseToProject(out, plan.coercedFields()) + result := convertResponseToProject(out, plan.coercedFields(), plan.Environment) tflog.Trace(ctx, "updated project", map[string]interface{}{ "team_id": result.TeamID.Value, "project_id": result.ID.Value, @@ -463,7 +495,7 @@ func (r resourceProject) ImportState(ctx context.Context, req resource.ImportSta ) } - out, err := r.p.client.GetProject(ctx, projectID, teamID) + out, err := r.p.client.GetProject(ctx, projectID, teamID, true) if err != nil { resp.Diagnostics.AddError( "Error reading project", @@ -484,7 +516,7 @@ func (r resourceProject) ImportState(ctx context.Context, req resource.ImportSta OutputDirectory: types.String{Null: true}, PublicSource: types.Bool{Null: true}, TeamID: types.String{Value: teamID, Null: teamID == ""}, - }) + }, types.Set{Null: true}) tflog.Trace(ctx, "imported project", map[string]interface{}{ "team_id": result.TeamID.Value, "project_id": result.ID.Value, diff --git a/vercel/resource_project_domain.go b/vercel/resource_project_domain.go index 4a4cd5af..042d14b4 100644 --- a/vercel/resource_project_domain.go +++ b/vercel/resource_project_domain.go @@ -101,7 +101,7 @@ func (r resourceProjectDomain) Create(ctx context.Context, req resource.CreateRe return } - _, err := r.p.client.GetProject(ctx, plan.ProjectID.Value, plan.TeamID.Value) + _, err := r.p.client.GetProject(ctx, plan.ProjectID.Value, plan.TeamID.Value, false) var apiErr client.APIError if err != nil && errors.As(err, &apiErr) && apiErr.StatusCode == 404 { resp.Diagnostics.AddError( diff --git a/vercel/resource_project_domain_test.go b/vercel/resource_project_domain_test.go index da0ea9f4..153ff970 100644 --- a/vercel/resource_project_domain_test.go +++ b/vercel/resource_project_domain_test.go @@ -12,7 +12,6 @@ import ( ) func TestAcc_ProjectDomain(t *testing.T) { - t.Parallel() testTeamID := resource.TestCheckNoResourceAttr("vercel_project.test", "team_id") if testTeam() != "" { testTeamID = resource.TestCheckResourceAttr("vercel_project.test", "team_id", testTeam()) diff --git a/vercel/resource_project_environment_variable.go b/vercel/resource_project_environment_variable.go new file mode 100644 index 00000000..f96f30c6 --- /dev/null +++ b/vercel/resource_project_environment_variable.go @@ -0,0 +1,300 @@ +package vercel + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/vercel/terraform-provider-vercel/client" +) + +type resourceProjectEnvironmentVariableType struct{} + +// GetSchema returns the schema information for a project environment variable resource. +func (r resourceProjectEnvironmentVariableType) GetSchema(_ context.Context) (tfsdk.Schema, diag.Diagnostics) { + return tfsdk.Schema{ + 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. +`, + Attributes: map[string]tfsdk.Attribute{ + "target": { + Required: true, + Description: "The environments that the Environment Variable should be present on. Valid targets are either `production`, `preview`, or `development`.", + Type: types.SetType{ + ElemType: types.StringType, + }, + }, + "key": { + Required: true, + PlanModifiers: tfsdk.AttributePlanModifiers{resource.RequiresReplace()}, + Description: "The name of the Environment Variable.", + Type: types.StringType, + }, + "value": { + Required: true, + Description: "The value of the Environment Variable.", + Type: types.StringType, + }, + "git_branch": { + Optional: true, + Description: "The git branch of the Environment Variable.", + Type: types.StringType, + }, + "project_id": { + Required: true, + Description: "The ID of the Vercel project.", + PlanModifiers: tfsdk.AttributePlanModifiers{resource.RequiresReplace()}, + Type: types.StringType, + }, + "team_id": { + Optional: true, + Description: "The ID of the Vercel team.", + PlanModifiers: tfsdk.AttributePlanModifiers{resource.RequiresReplace()}, + Type: types.StringType, + }, + "id": { + Description: "The ID of the Environment Variable.", + Type: types.StringType, + PlanModifiers: tfsdk.AttributePlanModifiers{resource.UseStateForUnknown()}, + Computed: true, + }, + }, + }, nil +} + +// NewResource instantiates a new Resource of this ResourceType. +func (r resourceProjectEnvironmentVariableType) NewResource(_ context.Context, p provider.Provider) (resource.Resource, diag.Diagnostics) { + return resourceProjectEnvironmentVariable{ + p: *(p.(*vercelProvider)), + }, nil +} + +type resourceProjectEnvironmentVariable struct { + p vercelProvider +} + +// Create will create a new project environment variable for a Vercel project. +// This is called automatically by the provider when a new resource should be created. +func (r resourceProjectEnvironmentVariable) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + if !r.p.configured { + resp.Diagnostics.AddError( + "Provider not configured", + "The provider hasn't been configured before apply. This leads to weird stuff happening, so we'd prefer if you didn't do that. Thanks!", + ) + return + } + + var plan ProjectEnvironmentVariable + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + _, err := r.p.client.GetProject(ctx, plan.ProjectID.Value, plan.TeamID.Value, false) + var apiErr client.APIError + if err != nil && errors.As(err, &apiErr) && apiErr.StatusCode == 404 { + resp.Diagnostics.AddError( + "Error creating project environment variable", + "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.p.client.CreateEnvironmentVariable(ctx, plan.toCreateEnvironmentVariableRequest()) + if err != nil { + resp.Diagnostics.AddError( + "Error creating project environment variable", + "Could not create project environment variable, unexpected error: "+err.Error(), + ) + return + } + + result := convertResponseToProjectEnvironmentVariable(response, plan.TeamID, plan.ProjectID) + + tflog.Trace(ctx, "created project environment variable", map[string]interface{}{ + "id": result.ID.Value, + "team_id": result.TeamID.Value, + "project_id": result.ProjectID.Value, + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Read will read an environment variable of a Vercel project by requesting it from the Vercel API, and will update terraform +// with this information. +func (r resourceProjectEnvironmentVariable) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state ProjectEnvironmentVariable + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.p.client.GetEnvironmentVariable(ctx, state.ProjectID.Value, state.TeamID.Value, state.ID.Value) + 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.Value, + state.ProjectID.Value, + state.TeamID.Value, + err, + ), + ) + return + } + + result := convertResponseToProjectEnvironmentVariable(out, state.TeamID, state.ProjectID) + tflog.Trace(ctx, "read project environment variable", map[string]interface{}{ + "id": result.ID.Value, + "team_id": result.TeamID.Value, + "project_id": result.ProjectID.Value, + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Update updates the project environment variable of a Vercel project state. +func (r resourceProjectEnvironmentVariable) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan ProjectEnvironmentVariable + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + response, err := r.p.client.UpdateEnvironmentVariable(ctx, plan.toUpdateEnvironmentVariableRequest()) + if err != nil { + resp.Diagnostics.AddError( + "Error updating project environment variable", + "Could not update project environment variable, unexpected error: "+err.Error(), + ) + return + } + + result := convertResponseToProjectEnvironmentVariable(response, plan.TeamID, plan.ProjectID) + + tflog.Trace(ctx, "updated project environment variable", map[string]interface{}{ + "id": result.ID.Value, + "team_id": result.TeamID.Value, + "project_id": result.ProjectID.Value, + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Delete deletes a Vercel project environment variable. +func (r resourceProjectEnvironmentVariable) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state ProjectEnvironmentVariable + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + err := r.p.client.DeleteEnvironmentVariable(ctx, state.ProjectID.Value, state.TeamID.Value, state.ID.Value) + if client.NotFound(err) { + return + } + 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.Value, + err, + ), + ) + return + } + + tflog.Trace(ctx, "deleted project environment variable", map[string]interface{}{ + "id": state.ID.Value, + "team_id": state.TeamID.Value, + "project_id": state.ProjectID.Value, + }) +} + +// splitID is a helper function for splitting an import ID into the corresponding parts. +// It also validates whether the ID is in a correct format. +func splitProjectEnvironmentVariableID(id string) (teamID, projectID, envID string, ok bool) { + attributes := strings.Split(id, "/") + if len(attributes) == 3 { + return attributes[0], attributes[1], attributes[2], true + } + if len(attributes) == 2 { + return "", attributes[0], attributes[1], true + } + + return "", "", "", false +} + +// ImportState takes an identifier and reads all the project environment variable information from the Vercel API. +// The results are then stored in terraform state. +func (r resourceProjectEnvironmentVariable) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + teamID, projectID, envID, ok := splitProjectEnvironmentVariableID(req.ID) + if !ok { + resp.Diagnostics.AddError( + "Error importing project environment variable", + fmt.Sprintf("Invalid id '%s' specified. should be in format \"team_id/project_id/env_id\" or \"project_id/env_id\"", req.ID), + ) + } + + out, err := r.p.client.GetEnvironmentVariable(ctx, projectID, teamID, envID) + 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", + teamID, + projectID, + envID, + err, + ), + ) + return + } + + result := convertResponseToProjectEnvironmentVariable(out, types.String{Value: teamID, Null: teamID == ""}, types.String{Value: projectID}) + tflog.Trace(ctx, "imported project environment variable", map[string]interface{}{ + "team_id": result.TeamID.Value, + "project_id": result.ProjectID.Value, + "env_id": result.ID.Value, + }) + + diags := resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} diff --git a/vercel/resource_project_environment_variable_model.go b/vercel/resource_project_environment_variable_model.go new file mode 100644 index 00000000..853522b8 --- /dev/null +++ b/vercel/resource_project_environment_variable_model.go @@ -0,0 +1,70 @@ +package vercel + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/vercel/terraform-provider-vercel/client" +) + +// ProjectEnvironmentVariable reflects the state terraform stores internally for a project environment variable. +type ProjectEnvironmentVariable struct { + Target []types.String `tfsdk:"target"` + GitBranch types.String `tfsdk:"git_branch"` + Key types.String `tfsdk:"key"` + Value types.String `tfsdk:"value"` + TeamID types.String `tfsdk:"team_id"` + ProjectID types.String `tfsdk:"project_id"` + ID types.String `tfsdk:"id"` +} + +func (e *ProjectEnvironmentVariable) toCreateEnvironmentVariableRequest() client.CreateEnvironmentVariableRequest { + var target []string + for _, t := range e.Target { + target = append(target, t.Value) + } + return client.CreateEnvironmentVariableRequest{ + Key: e.Key.Value, + Value: e.Value.Value, + Target: target, + GitBranch: toStrPointer(e.GitBranch), + Type: "encrypted", + ProjectID: e.ProjectID.Value, + TeamID: e.TeamID.Value, + } +} + +func (e *ProjectEnvironmentVariable) toUpdateEnvironmentVariableRequest() client.UpdateEnvironmentVariableRequest { + var target []string + for _, t := range e.Target { + target = append(target, t.Value) + } + return client.UpdateEnvironmentVariableRequest{ + Key: e.Key.Value, + Value: e.Value.Value, + Target: target, + GitBranch: toStrPointer(e.GitBranch), + Type: "encrypted", + ProjectID: e.ProjectID.Value, + TeamID: e.TeamID.Value, + EnvID: e.ID.Value, + } +} + +// convertResponseToProjectEnvironmentVariable 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 convertResponseToProjectEnvironmentVariable(response client.EnvironmentVariable, teamID, projectID types.String) ProjectEnvironmentVariable { + target := []types.String{} + for _, t := range response.Target { + target = append(target, types.String{Value: t}) + } + + return ProjectEnvironmentVariable{ + Target: target, + GitBranch: fromStringPointer(response.GitBranch), + Key: types.String{Value: response.Key}, + Value: types.String{Value: response.Value}, + TeamID: teamID, + ProjectID: projectID, + ID: types.String{Value: response.ID}, + } +} diff --git a/vercel/resource_project_environment_variable_test.go b/vercel/resource_project_environment_variable_test.go new file mode 100644 index 00000000..915e3515 --- /dev/null +++ b/vercel/resource_project_environment_variable_test.go @@ -0,0 +1,207 @@ +package vercel_test + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func testAccProjectEnvironmentVariableExists(n, teamID string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("no ID is set") + } + + _, err := testClient().GetEnvironmentVariable(context.TODO(), rs.Primary.Attributes["project_id"], teamID, rs.Primary.ID) + return err + } +} + +func testAccProjectEnvironmentVariablesDoNotExist(n, teamID string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("no ID is set") + } + + project, err := testClient().GetProject(context.TODO(), rs.Primary.ID, teamID, true) + if err != nil { + return fmt.Errorf("could not fetch the project: %w", err) + } + + if len(project.EnvironmentVariables) != 0 { + return fmt.Errorf("project environment variables not deleted, they still exist") + } + + return nil + } +} + +func TestAcc_ProjectEnvironmentVariables(t *testing.T) { + nameSuffix := acctest.RandString(16) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: resource.ComposeAggregateTestCheckFunc( + testAccProjectDestroy("vercel_project.example", testTeam()), + ), + Steps: []resource.TestStep{ + { + Config: testAccProjectEnvironmentVariablesConfig(nameSuffix), + Check: resource.ComposeAggregateTestCheckFunc( + testAccProjectEnvironmentVariableExists("vercel_project_environment_variable.example", testTeam()), + resource.TestCheckResourceAttr("vercel_project_environment_variable.example", "key", "foo"), + resource.TestCheckResourceAttr("vercel_project_environment_variable.example", "value", "bar"), + resource.TestCheckTypeSetElemAttr("vercel_project_environment_variable.example", "target.*", "production"), + + testAccProjectEnvironmentVariableExists("vercel_project_environment_variable.example_git_branch", testTeam()), + resource.TestCheckResourceAttr("vercel_project_environment_variable.example_git_branch", "key", "foo"), + resource.TestCheckResourceAttr("vercel_project_environment_variable.example_git_branch", "value", "bar-staging"), + resource.TestCheckTypeSetElemAttr("vercel_project_environment_variable.example_git_branch", "target.*", "preview"), + resource.TestCheckResourceAttr("vercel_project_environment_variable.example_git_branch", "git_branch", "production"), + ), + }, + { + Config: testAccProjectEnvironmentVariablesConfigUpdated(nameSuffix), + Check: resource.ComposeAggregateTestCheckFunc( + testAccProjectEnvironmentVariableExists("vercel_project_environment_variable.example", testTeam()), + resource.TestCheckResourceAttr("vercel_project_environment_variable.example", "key", "foo"), + resource.TestCheckResourceAttr("vercel_project_environment_variable.example", "value", "bar-new"), + resource.TestCheckTypeSetElemAttr("vercel_project_environment_variable.example", "target.*", "production"), + resource.TestCheckTypeSetElemAttr("vercel_project_environment_variable.example", "target.*", "preview"), + + testAccProjectEnvironmentVariableExists("vercel_project_environment_variable.example_git_branch", testTeam()), + resource.TestCheckResourceAttr("vercel_project_environment_variable.example_git_branch", "key", "foo"), + resource.TestCheckResourceAttr("vercel_project_environment_variable.example_git_branch", "value", "bar-staging"), + resource.TestCheckTypeSetElemAttr("vercel_project_environment_variable.example_git_branch", "target.*", "preview"), + resource.TestCheckResourceAttr("vercel_project_environment_variable.example_git_branch", "git_branch", "test"), + ), + }, + { + ResourceName: "vercel_project_environment_variable.example", + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: getProjectEnvironmentVariableImportID("vercel_project_environment_variable.example"), + }, + { + ResourceName: "vercel_project_environment_variable.example_git_branch", + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: getProjectEnvironmentVariableImportID("vercel_project_environment_variable.example_git_branch"), + }, + { + Config: testAccProjectEnvironmentVariablesConfigDeleted(nameSuffix), + Check: resource.ComposeAggregateTestCheckFunc( + testAccProjectEnvironmentVariablesDoNotExist("vercel_project.example", testTeam()), + ), + }, + }, + }) +} + +func getProjectEnvironmentVariableImportID(n string) resource.ImportStateIdFunc { + return func(s *terraform.State) (string, error) { + rs, ok := s.RootModule().Resources[n] + if !ok { + return "", fmt.Errorf("not found: %s", n) + } + + if rs.Primary.ID == "" { + return "", fmt.Errorf("no ID is set") + } + + if rs.Primary.Attributes["team_id"] == "" { + return fmt.Sprintf("%s/%s", rs.Primary.Attributes["project_id"], rs.Primary.ID), nil + } + return fmt.Sprintf("%s/%s/%s", rs.Primary.Attributes["team_id"], rs.Primary.Attributes["project_id"], rs.Primary.ID), nil + } +} + +func testAccProjectEnvironmentVariablesConfig(projectName string) string { + return fmt.Sprintf(` +resource "vercel_project" "example" { + name = "test-acc-example-project-%[1]s" + %[3]s + + git_repository = { + type = "github" + repo = "%[2]s" + } +} + +resource "vercel_project_environment_variable" "example" { + project_id = vercel_project.example.id + %[3]s + key = "foo" + value = "bar" + target = ["production"] +} + +resource "vercel_project_environment_variable" "example_git_branch" { + project_id = vercel_project.example.id + %[3]s + key = "foo" + value = "bar-staging" + target = ["preview"] + git_branch = "production" +} +`, projectName, testGithubRepo(), teamIDConfig()) +} + +func testAccProjectEnvironmentVariablesConfigUpdated(projectName string) string { + return fmt.Sprintf(` +resource "vercel_project" "example" { + name = "test-acc-example-project-%[1]s" + %[3]s + + git_repository = { + type = "github" + repo = "%[2]s" + } +} + +resource "vercel_project_environment_variable" "example" { + project_id = vercel_project.example.id + %[3]s + key = "foo" + value = "bar-new" + target = ["production", "preview"] +} + +resource "vercel_project_environment_variable" "example_git_branch" { + project_id = vercel_project.example.id + %[3]s + key = "foo" + value = "bar-staging" + target = ["preview"] + git_branch = "test" +} +`, projectName, testGithubRepo(), teamIDConfig()) +} + +func testAccProjectEnvironmentVariablesConfigDeleted(projectName string) string { + return fmt.Sprintf(` +resource "vercel_project" "example" { + name = "test-acc-example-project-%[1]s" + %[3]s + + git_repository = { + type = "github" + repo = "%[2]s" + } +} +`, projectName, testGithubRepo(), teamIDConfig()) +} diff --git a/vercel/resource_project_model.go b/vercel/resource_project_model.go index 319beb60..815e1d92 100644 --- a/vercel/resource_project_model.go +++ b/vercel/resource_project_model.go @@ -1,26 +1,43 @@ package vercel import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/vercel/terraform-provider-vercel/client" ) // Project reflects the state terraform stores internally for a project. type Project struct { - BuildCommand types.String `tfsdk:"build_command"` - DevCommand types.String `tfsdk:"dev_command"` - Environment []EnvironmentItem `tfsdk:"environment"` - Framework types.String `tfsdk:"framework"` - GitRepository *GitRepository `tfsdk:"git_repository"` - ID types.String `tfsdk:"id"` - IgnoreCommand types.String `tfsdk:"ignore_command"` - InstallCommand types.String `tfsdk:"install_command"` - Name types.String `tfsdk:"name"` - OutputDirectory types.String `tfsdk:"output_directory"` - PublicSource types.Bool `tfsdk:"public_source"` - RootDirectory types.String `tfsdk:"root_directory"` - ServerlessFunctionRegion types.String `tfsdk:"serverless_function_region"` - TeamID types.String `tfsdk:"team_id"` + BuildCommand types.String `tfsdk:"build_command"` + DevCommand types.String `tfsdk:"dev_command"` + Environment types.Set `tfsdk:"environment"` + Framework types.String `tfsdk:"framework"` + GitRepository *GitRepository `tfsdk:"git_repository"` + ID types.String `tfsdk:"id"` + IgnoreCommand types.String `tfsdk:"ignore_command"` + InstallCommand types.String `tfsdk:"install_command"` + Name types.String `tfsdk:"name"` + OutputDirectory types.String `tfsdk:"output_directory"` + PublicSource types.Bool `tfsdk:"public_source"` + RootDirectory types.String `tfsdk:"root_directory"` + ServerlessFunctionRegion types.String `tfsdk:"serverless_function_region"` + TeamID types.String `tfsdk:"team_id"` +} + +func (p *Project) environment(ctx context.Context) ([]EnvironmentItem, error) { + if p.Environment.Null { + return nil, nil + } + + var vars []EnvironmentItem + err := p.Environment.ElementsAs(ctx, &vars, true) + if err != nil { + return nil, fmt.Errorf("error reading project environment variables: %s", err) + } + return vars, nil } func parseEnvironment(vars []EnvironmentItem) []client.EnvironmentVariable { @@ -43,12 +60,12 @@ func parseEnvironment(vars []EnvironmentItem) []client.EnvironmentVariable { return out } -func (p *Project) toCreateProjectRequest() client.CreateProjectRequest { +func (p *Project) toCreateProjectRequest(envs []EnvironmentItem) client.CreateProjectRequest { return client.CreateProjectRequest{ BuildCommand: toStrPointer(p.BuildCommand), CommandForIgnoringBuildStep: toStrPointer(p.IgnoreCommand), DevCommand: toStrPointer(p.DevCommand), - EnvironmentVariables: parseEnvironment(p.Environment), + EnvironmentVariables: parseEnvironment(envs), Framework: toStrPointer(p.Framework), GitRepository: p.GitRepository.toCreateProjectRequest(), InstallCommand: toStrPointer(p.InstallCommand), @@ -88,18 +105,19 @@ type EnvironmentItem struct { ID types.String `tfsdk:"id"` } -func (e *EnvironmentItem) toUpsertEnvironmentVariableRequest() client.UpsertEnvironmentVariableRequest { +func (e *EnvironmentItem) toCreateEnvironmentVariableRequest(projectID, teamID string) client.CreateEnvironmentVariableRequest { var target []string for _, t := range e.Target { target = append(target, t.Value) } - return client.UpsertEnvironmentVariableRequest{ + return client.CreateEnvironmentVariableRequest{ Key: e.Key.Value, Value: e.Value.Value, Target: target, GitBranch: toStrPointer(e.GitBranch), Type: "encrypted", - ID: e.ID.Value, + ProjectID: projectID, + TeamID: teamID, } } @@ -167,7 +185,7 @@ func uncoerceBool(plan, res types.Bool) types.Bool { return res } -func convertResponseToProject(response client.ProjectResponse, fields projectCoercedFields) Project { +func convertResponseToProject(response client.ProjectResponse, fields projectCoercedFields, environment types.Set) Project { var gr *GitRepository if repo := response.Repository(); repo != nil { gr = &GitRepository{ @@ -175,25 +193,54 @@ func convertResponseToProject(response client.ProjectResponse, fields projectCoe Repo: types.String{Value: repo.Repo}, } } - var env []EnvironmentItem + + var env []attr.Value for _, e := range response.EnvironmentVariables { - target := []types.String{} + target := []attr.Value{} for _, t := range e.Target { target = append(target, types.String{Value: t}) } - env = append(env, EnvironmentItem{ - Key: types.String{Value: e.Key}, - Value: types.String{Value: e.Value}, - Target: target, - GitBranch: fromStringPointer(e.GitBranch), - ID: types.String{Value: e.ID}, + env = append(env, types.Object{ + Attrs: map[string]attr.Value{ + "key": types.String{Value: e.Key}, + "value": types.String{Value: e.Value}, + "target": types.Set{ + Elems: target, + ElemType: types.StringType, + }, + "git_branch": fromStringPointer(e.GitBranch), + "id": types.String{Value: e.ID}, + }, + AttrTypes: map[string]attr.Type{ + "key": types.StringType, + "value": types.StringType, + "target": types.SetType{ + ElemType: types.StringType, + }, + "git_branch": types.StringType, + "id": types.StringType, + }, }) } return Project{ - BuildCommand: uncoerceString(fields.BuildCommand, fromStringPointer(response.BuildCommand)), - DevCommand: uncoerceString(fields.DevCommand, fromStringPointer(response.DevCommand)), - Environment: env, + BuildCommand: uncoerceString(fields.BuildCommand, fromStringPointer(response.BuildCommand)), + DevCommand: uncoerceString(fields.DevCommand, fromStringPointer(response.DevCommand)), + Environment: types.Set{ + Null: len(response.EnvironmentVariables) == 0 && environment.Null, + Elems: env, + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "key": types.StringType, + "value": types.StringType, + "target": types.SetType{ + ElemType: types.StringType, + }, + "git_branch": types.StringType, + "id": types.StringType, + }, + }, + }, Framework: fromStringPointer(response.Framework), GitRepository: gr, ID: types.String{Value: response.ID}, diff --git a/vercel/resource_project_test.go b/vercel/resource_project_test.go index 5e7cd4a4..063231c7 100644 --- a/vercel/resource_project_test.go +++ b/vercel/resource_project_test.go @@ -63,7 +63,6 @@ func TestAcc_Project(t *testing.T) { } func TestAcc_ProjectAddingEnvAfterInitialCreation(t *testing.T) { - t.Parallel() projectSuffix := acctest.RandString(16) resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -87,7 +86,6 @@ func TestAcc_ProjectAddingEnvAfterInitialCreation(t *testing.T) { } func TestAcc_ProjectWithGitRepository(t *testing.T) { - t.Parallel() projectSuffix := acctest.RandString(16) resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -123,7 +121,6 @@ func TestAcc_ProjectWithGitRepository(t *testing.T) { } func TestAcc_ProjectImport(t *testing.T) { - t.Parallel() projectSuffix := acctest.RandString(16) resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -156,7 +153,7 @@ func testAccProjectExists(n, teamID string) resource.TestCheckFunc { return fmt.Errorf("no projectID is set") } - _, err := testClient().GetProject(context.TODO(), rs.Primary.ID, teamID) + _, err := testClient().GetProject(context.TODO(), rs.Primary.ID, teamID, false) return err } } @@ -172,7 +169,7 @@ func testAccProjectDestroy(n, teamID string) resource.TestCheckFunc { return fmt.Errorf("no projectID is set") } - _, err := testClient().GetProject(context.TODO(), rs.Primary.ID, teamID) + _, err := testClient().GetProject(context.TODO(), rs.Primary.ID, teamID, false) if err == nil { return fmt.Errorf("expected not_found error, but got no error") }