diff --git a/client/custom_environment.go b/client/custom_environment.go new file mode 100644 index 00000000..53df075e --- /dev/null +++ b/client/custom_environment.go @@ -0,0 +1,138 @@ +package client + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +type BranchMatcher struct { + Pattern string `json:"pattern"` + Type string `json:"type"` +} + +type CreateCustomEnvironmentRequest struct { + TeamID string `json:"-"` + ProjectID string `json:"-"` + Slug string `json:"slug"` + Description string `json:"description"` + BranchMatcher *BranchMatcher `json:"branchMatcher,omitempty"` +} + +type CustomEnvironmentResponse struct { + ID string `json:"id"` + Description string `json:"description"` + Slug string `json:"slug"` + BranchMatcher *BranchMatcher `json:"branchMatcher"` + TeamID string `json:"-"` + ProjectID string `json:"-"` +} + +func (c *Client) CreateCustomEnvironment(ctx context.Context, request CreateCustomEnvironmentRequest) (res CustomEnvironmentResponse, err error) { + url := fmt.Sprintf("%s/v1/projects/%s/custom-environments", c.baseURL, request.ProjectID) + if c.teamID(request.TeamID) != "" { + url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(request.TeamID)) + } + payload := string(mustMarshal(request)) + tflog.Info(ctx, "creating custom environment", map[string]interface{}{ + "url": url, + "payload": payload, + }) + err = c.doRequest(clientRequest{ + ctx: ctx, + method: "POST", + url: url, + body: payload, + }, &res) + if err != nil { + return res, err + } + res.TeamID = c.teamID(request.TeamID) + res.ProjectID = request.ProjectID + return res, nil +} + +type GetCustomEnvironmentRequest struct { + TeamID string `json:"-"` + ProjectID string `json:"-"` + Slug string `json:"-"` +} + +func (c *Client) GetCustomEnvironment(ctx context.Context, request GetCustomEnvironmentRequest) (res CustomEnvironmentResponse, err error) { + url := fmt.Sprintf("%s/v1/projects/%s/custom-environments/%s", c.baseURL, request.ProjectID, request.Slug) + if c.teamID(request.TeamID) != "" { + url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(request.TeamID)) + } + tflog.Info(ctx, "getting custom environment", map[string]interface{}{ + "url": url, + }) + err = c.doRequest(clientRequest{ + ctx: ctx, + method: "GET", + url: url, + }, &res) + if err != nil { + return res, err + } + res.TeamID = c.teamID(request.TeamID) + res.ProjectID = request.ProjectID + return res, nil + +} + +type UpdateCustomEnvironmentRequest struct { + TeamID string `json:"-"` + ProjectID string `json:"-"` + OldSlug string `json:"-"` // Needed to get the right URL + Slug string `json:"slug"` + Description string `json:"description"` + BranchMatcher *BranchMatcher `json:"branchMatcher"` +} + +func (c *Client) UpdateCustomEnvironment(ctx context.Context, request UpdateCustomEnvironmentRequest) (res CustomEnvironmentResponse, err error) { + url := fmt.Sprintf("%s/v1/projects/%s/custom-environments/%s", c.baseURL, request.ProjectID, request.OldSlug) + if c.teamID(request.TeamID) != "" { + url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(request.TeamID)) + } + payload := string(mustMarshal(request)) + tflog.Info(ctx, "updating custom environment", map[string]interface{}{ + "url": url, + "payload": payload, + }) + err = c.doRequest(clientRequest{ + ctx: ctx, + method: "PATCH", + url: url, + body: payload, + }, &res) + if err != nil { + return res, err + } + res.TeamID = c.teamID(request.TeamID) + res.ProjectID = request.ProjectID + return res, nil +} + +type DeleteCustomEnvironmentRequest struct { + TeamID string `json:"-"` + ProjectID string `json:"-"` + Slug string `json:"-"` +} + +func (c *Client) DeleteCustomEnvironment(ctx context.Context, request DeleteCustomEnvironmentRequest) error { + url := fmt.Sprintf("%s/v1/projects/%s/custom-environments/%s", c.baseURL, request.ProjectID, request.Slug) + if c.teamID(request.TeamID) != "" { + url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(request.TeamID)) + } + tflog.Info(ctx, "deleting custom environment", map[string]interface{}{ + "url": url, + }) + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "DELETE", + url: url, + body: "{ \"deleteUnassignedEnvironmentVariables\": true }", + }, nil) + return err +} diff --git a/client/environment_variable.go b/client/environment_variable.go index 093daced..e97fba04 100644 --- a/client/environment_variable.go +++ b/client/environment_variable.go @@ -10,12 +10,13 @@ import ( // CreateEnvironmentVariableRequest defines the information that needs to be passed to Vercel in order to // create an environment variable. type EnvironmentVariableRequest struct { - Key string `json:"key"` - Value string `json:"value"` - Target []string `json:"target"` - GitBranch *string `json:"gitBranch,omitempty"` - Type string `json:"type"` - Comment string `json:"comment"` + Key string `json:"key"` + Value string `json:"value"` + Target []string `json:"target,omitempty"` + CustomEnvironmentIDs []string `json:"customEnvironmentIds,omitempty"` + GitBranch *string `json:"gitBranch,omitempty"` + Type string `json:"type"` + Comment string `json:"comment"` } type CreateEnvironmentVariableRequest struct { @@ -58,7 +59,7 @@ func (c *Client) CreateEnvironmentVariable(ctx context.Context, request CreateEn } } if err != nil { - return e, err + return e, fmt.Errorf("%w - %s", err, payload) } // The API response returns an encrypted environment variable, but we want to return the decrypted version. e.Value = request.EnvironmentVariable.Value @@ -127,6 +128,14 @@ type CreateEnvironmentVariablesResponse struct { } func (c *Client) CreateEnvironmentVariables(ctx context.Context, request CreateEnvironmentVariablesRequest) ([]EnvironmentVariable, error) { + if len(request.EnvironmentVariables) == 1 { + env, err := c.CreateEnvironmentVariable(ctx, CreateEnvironmentVariableRequest{ + EnvironmentVariable: request.EnvironmentVariables[0], + ProjectID: request.ProjectID, + TeamID: request.TeamID, + }) + return []EnvironmentVariable{env}, err + } 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)) @@ -145,7 +154,7 @@ func (c *Client) CreateEnvironmentVariables(ctx context.Context, request CreateE body: payload, }, &response) if err != nil { - return nil, err + return nil, fmt.Errorf("%w - %s", err, payload) } if len(response.Failed) > 0 { @@ -176,14 +185,15 @@ func (c *Client) CreateEnvironmentVariables(ctx context.Context, request CreateE // UpdateEnvironmentVariableRequest defines the information that needs to be passed to Vercel in order to // update an environment variable. type UpdateEnvironmentVariableRequest struct { - Value string `json:"value"` - Target []string `json:"target"` - GitBranch *string `json:"gitBranch,omitempty"` - Type string `json:"type"` - Comment string `json:"comment"` - ProjectID string `json:"-"` - TeamID string `json:"-"` - EnvID string `json:"-"` + Value string `json:"value"` + Target []string `json:"target"` + CustomEnvironmentIDs []string `json:"customEnvironmentIds,omitempty"` + GitBranch *string `json:"gitBranch,omitempty"` + Type string `json:"type"` + Comment string `json:"comment"` + ProjectID string `json:"-"` + TeamID string `json:"-"` + EnvID string `json:"-"` } // UpdateEnvironmentVariable will update an existing environment variable to the latest information. diff --git a/client/project.go b/client/project.go index db33a540..f16d0cf2 100644 --- a/client/project.go +++ b/client/project.go @@ -23,15 +23,16 @@ type OIDCTokenConfig struct { // EnvironmentVariable defines the information Vercel requires and surfaces about an environment variable // that is associated with a project. type EnvironmentVariable struct { - Key string `json:"key"` - Value string `json:"value"` - Target []string `json:"target"` - GitBranch *string `json:"gitBranch,omitempty"` - Type string `json:"type"` - ID string `json:"id,omitempty"` - TeamID string `json:"-"` - Comment string `json:"comment"` - Decrypted bool `json:"decrypted"` + Key string `json:"key"` + Value string `json:"value"` + Target []string `json:"target"` + CustomEnvironmentIDs []string `json:"customEnvironmentIds"` + GitBranch *string `json:"gitBranch,omitempty"` + Type string `json:"type"` + ID string `json:"id,omitempty"` + TeamID string `json:"-"` + Comment string `json:"comment"` + Decrypted bool `json:"decrypted"` } type DeploymentExpiration struct { @@ -46,7 +47,7 @@ type CreateProjectRequest struct { BuildCommand *string `json:"buildCommand"` CommandForIgnoringBuildStep *string `json:"commandForIgnoringBuildStep,omitempty"` DevCommand *string `json:"devCommand"` - EnvironmentVariables []EnvironmentVariable `json:"environmentVariables"` + EnvironmentVariables []EnvironmentVariable `json:"environmentVariables,omitempty"` Framework *string `json:"framework"` GitRepository *GitRepository `json:"gitRepository,omitempty"` InstallCommand *string `json:"installCommand"` diff --git a/client/project_domain.go b/client/project_domain.go index e6e8df97..d0f22fc2 100644 --- a/client/project_domain.go +++ b/client/project_domain.go @@ -12,10 +12,11 @@ import ( // used to assign a domain name to any production deployments, but can also be used to configure // redirects, or to give specific git branches a domain name. type CreateProjectDomainRequest struct { - Name string `json:"name"` - GitBranch string `json:"gitBranch,omitempty"` - Redirect string `json:"redirect,omitempty"` - RedirectStatusCode int64 `json:"redirectStatusCode,omitempty"` + Name string `json:"name"` + GitBranch string `json:"gitBranch,omitempty"` + CustomEnvironmentID string `json:"customEnvironmentId,omitempty"` + Redirect string `json:"redirect,omitempty"` + RedirectStatusCode int64 `json:"redirectStatusCode,omitempty"` } // CreateProjectDomain creates a project domain within Vercel. @@ -61,12 +62,13 @@ func (c *Client) DeleteProjectDomain(ctx context.Context, projectID, domain, tea // ProjectDomainResponse defines the information that Vercel exposes about a domain that is // associated with a vercel project. type ProjectDomainResponse struct { - Name string `json:"name"` - ProjectID string `json:"projectId"` - TeamID string `json:"-"` - Redirect *string `json:"redirect"` - RedirectStatusCode *int64 `json:"redirectStatusCode"` - GitBranch *string `json:"gitBranch"` + Name string `json:"name"` + ProjectID string `json:"projectId"` + TeamID string `json:"-"` + Redirect *string `json:"redirect"` + RedirectStatusCode *int64 `json:"redirectStatusCode"` + GitBranch *string `json:"gitBranch"` + CustomEnvironmentID *string `json:"customEnvironmentId"` } // GetProjectDomain retrieves information about a project domain from Vercel. @@ -91,9 +93,10 @@ func (c *Client) GetProjectDomain(ctx context.Context, projectID, domain, teamID // UpdateProjectDomainRequest defines the information necessary to update a project domain. type UpdateProjectDomainRequest struct { - GitBranch *string `json:"gitBranch"` - Redirect *string `json:"redirect"` - RedirectStatusCode *int64 `json:"redirectStatusCode"` + GitBranch *string `json:"gitBranch"` + CustomEnvironmentID *string `json:"customEnvironmentId,omitempty"` + Redirect *string `json:"redirect"` + RedirectStatusCode *int64 `json:"redirectStatusCode"` } // UpdateProjectDomain updates an existing project domain within Vercel. diff --git a/client/request.go b/client/request.go index 50a60241..a366b6c9 100644 --- a/client/request.go +++ b/client/request.go @@ -126,8 +126,11 @@ func (c *Client) _doRequest(req *http.Request, v interface{}, errorOnNoContent b }{ Error: &errorResponse, }) + if errorResponse.Code == "" && errorResponse.Message == "" { + return fmt.Errorf("error performing API request: %d %s", resp.StatusCode, string(responseBody)) + } if err != nil { - return fmt.Errorf("error unmarshaling response for status code %d: %w", resp.StatusCode, err) + return fmt.Errorf("error unmarshaling response for status code %d: %w: %s", resp.StatusCode, err, string(responseBody)) } errorResponse.StatusCode = resp.StatusCode errorResponse.RawMessage = responseBody diff --git a/docs/data-sources/custom_environment.md b/docs/data-sources/custom_environment.md new file mode 100644 index 00000000..26a090c7 --- /dev/null +++ b/docs/data-sources/custom_environment.md @@ -0,0 +1,53 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_custom_environment Data Source - terraform-provider-vercel" +subcategory: "" +description: |- + Provides information about an existing CustomEnvironment resource. + An CustomEnvironment allows a vercel_deployment to be accessed through a different URL. +--- + +# vercel_custom_environment (Data Source) + +Provides information about an existing CustomEnvironment resource. + +An CustomEnvironment allows a `vercel_deployment` to be accessed through a different URL. + +## Example Usage + +```terraform +data "vercel_project" "example" { + name = "example-project-with-custom-env" +} + +data "vercel_custom_environment" "example" { + project_id = data.vercel_project.example.id + name = "example-custom-env" +} +``` + + +## Schema + +### Required + +- `name` (String) The name of the environment. +- `project_id` (String) The ID of the existing Vercel Project. + +### Optional + +- `team_id` (String) The team ID to add the project to. Required when configuring a team resource if a default team has not been set in the provider. + +### Read-Only + +- `branch_tracking` (Attributes) The branch tracking configuration for the environment. When enabled, each qualifying merge will generate a deployment. (see [below for nested schema](#nestedatt--branch_tracking)) +- `description` (String) A description of what the environment is. +- `id` (String) The ID of the environment. + + +### Nested Schema for `branch_tracking` + +Read-Only: + +- `pattern` (String) The pattern of the branch name to track. +- `type` (String) How a branch name should be matched against the pattern. Must be one of 'startsWith', 'endsWith' or 'equals'. diff --git a/docs/data-sources/project.md b/docs/data-sources/project.md index 78c3a4a2..cc53fed9 100644 --- a/docs/data-sources/project.md +++ b/docs/data-sources/project.md @@ -75,6 +75,7 @@ data "vercel_project" "example" { Read-Only: - `comment` (String) A comment explaining what the environment variable is for. +- `custom_environment_ids` (Set of String) The IDs of Custom Environments that the Environment Variable should be present on. - `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. diff --git a/docs/resources/custom_environment.md b/docs/resources/custom_environment.md new file mode 100644 index 00000000..fac6ae10 --- /dev/null +++ b/docs/resources/custom_environment.md @@ -0,0 +1,82 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_custom_environment Resource - terraform-provider-vercel" +subcategory: "" +description: |- + Environments help manage the deployment lifecycle on the Vercel platform. + By default, all teams use three environments when developing their project: Production, Preview, and Development. However, teams can also create custom environments to suit their needs. To learn more about the limits for each plan, see limits. + Custom environments allow you to configure customized, pre-production environments for your project, such as staging or QA, with branch rules that will automatically deploy your branch when the branch name matches the rule. With custom environments you can also attach a domain to your environment, set environment variables, or import environment variables from another environment. + Custom environments are designed as pre-production environments intended for long-running use. This contrasts with regular preview environments, which are designed for creating ephemeral, short-lived deployments. +--- + +# vercel_custom_environment (Resource) + +Environments help manage the deployment lifecycle on the Vercel platform. + +By default, all teams use three environments when developing their project: Production, Preview, and Development. However, teams can also create custom environments to suit their needs. To learn more about the limits for each plan, see limits. + +Custom environments allow you to configure customized, pre-production environments for your project, such as staging or QA, with branch rules that will automatically deploy your branch when the branch name matches the rule. With custom environments you can also attach a domain to your environment, set environment variables, or import environment variables from another environment. + +Custom environments are designed as pre-production environments intended for long-running use. This contrasts with regular preview environments, which are designed for creating ephemeral, short-lived deployments. + +## Example Usage + +```terraform +resource "vercel_project" "example" { + name = "example-project-with-custom-env" +} + +resource "vercel_custom_environment" "example" { + project_id = vercel_project.example.id + name = "example-custom-env" + description = "A description of the custom environment" + branch_tracking = { + pattern = "staging-" + type = "startsWith" + } +} +``` + + +## Schema + +### Required + +- `name` (String) The name of the environment. +- `project_id` (String) The ID of the existing Vercel Project. + +### Optional + +- `branch_tracking` (Attributes) The branch tracking configuration for the environment. When enabled, each qualifying merge will generate a deployment. (see [below for nested schema](#nestedatt--branch_tracking)) +- `description` (String) A description of what the environment is. +- `team_id` (String) The team ID to add the project to. Required when configuring a team resource if a default team has not been set in the provider. + +### Read-Only + +- `id` (String) The ID of the environment. + + +### Nested Schema for `branch_tracking` + +Required: + +- `pattern` (String) The pattern of the branch name to track. +- `type` (String) How a branch name should be matched against the pattern. Must be one of 'startsWith', 'endsWith' or 'equals'. + +## Import + +Import is supported using the following syntax: + +```shell +# If importing into a personal account, or with a team configured on +# the provider, simply use the project_id and custom environment name. +# - project_id can be found in the project `settings` tab in the Vercel UI. +terraform import vercel_custom_environment.example prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx/example-custom-env + +# Alternatively, you can 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. +# - project_id can be found in the project `settings` tab in the Vercel UI. +# +# Note also, that the value field for sensitive environment variables will be imported as `null`. +terraform import vercel_custom_environment.example team_xxxxxxxxxxxxxxxxxxxxxxxx/prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx/example-custom-env +``` diff --git a/docs/resources/project.md b/docs/resources/project.md index 242c288a..e5c678bc 100644 --- a/docs/resources/project.md +++ b/docs/resources/project.md @@ -97,14 +97,15 @@ resource "vercel_project" "example" { 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, Sensitive) The value of the Environment Variable. Optional: - `comment` (String) A comment explaining what the environment variable is for. +- `custom_environment_ids` (Set of String) The IDs of Custom Environments that the Environment Variable should be present on. At least one of `target` or `custom_environment_ids` must be set. - `git_branch` (String) The git branch of the Environment Variable. - `sensitive` (Boolean) Whether the Environment Variable is sensitive or not. (May be affected by a [team-wide environment variable policy](https://vercel.com/docs/projects/environment-variables/sensitive-environment-variables#environment-variables-policy)) +- `target` (Set of String) The environments that the Environment Variable should be present on. Valid targets are either `production`, `preview`, or `development`. At least one of `target` or `custom_environment_ids` must be set. Read-Only: diff --git a/docs/resources/project_domain.md b/docs/resources/project_domain.md index 36f14583..509768be 100644 --- a/docs/resources/project_domain.md +++ b/docs/resources/project_domain.md @@ -51,6 +51,7 @@ resource "vercel_project_domain" "example_redirect" { ### Optional +- `custom_environment_id` (String) The name of the Custom Environment to link to the Project Domain. Deployments from this custom environment will be assigned the domain name. - `git_branch` (String) Git branch to link to the project domain. Deployments from this git branch will be assigned the domain name. - `redirect` (String) The domain name that serves as a target destination for redirects. - `redirect_status_code` (Number) The HTTP status code to use when serving as a redirect. diff --git a/docs/resources/project_environment_variable.md b/docs/resources/project_environment_variable.md index ee3a62ea..f6f8de47 100644 --- a/docs/resources/project_environment_variable.md +++ b/docs/resources/project_environment_variable.md @@ -73,14 +73,15 @@ resource "vercel_project_environment_variable" "example_sensitive" { - `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, Sensitive) The value of the Environment Variable. ### Optional - `comment` (String) A comment explaining what the environment variable is for. +- `custom_environment_ids` (Set of String) The IDs of Custom Environments that the Environment Variable should be present on. At least one of `target` or `custom_environment_ids` must be set. - `git_branch` (String) The git branch of the Environment Variable. - `sensitive` (Boolean) Whether the Environment Variable is sensitive or not. (May be affected by a [team-wide environment variable policy](https://vercel.com/docs/projects/environment-variables/sensitive-environment-variables#environment-variables-policy)) +- `target` (Set of String) The environments that the Environment Variable should be present on. Valid targets are either `production`, `preview`, or `development`. At least one of `target` or `custom_environment_ids` must be set. - `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. ### Read-Only diff --git a/docs/resources/project_environment_variables.md b/docs/resources/project_environment_variables.md index 3265ad68..eb6330ca 100644 --- a/docs/resources/project_environment_variables.md +++ b/docs/resources/project_environment_variables.md @@ -75,14 +75,15 @@ resource "vercel_project_environment_variables" "example" { 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. +- `custom_environment_ids` (Set of String) The IDs of Custom Environments that the Environment Variable should be present on. At least one of `target` or `custom_environment_ids` must be set. - `git_branch` (String) The git branch of the Environment Variable. - `sensitive` (Boolean) Whether the Environment Variable is sensitive or not. +- `target` (Set of String) The environments that the Environment Variable should be present on. Valid targets are either `production`, `preview`, or `development`. At least one of `target` or `custom_environment_ids` must be set. Read-Only: diff --git a/examples/data-sources/vercel_custom_environment/data-source.tf b/examples/data-sources/vercel_custom_environment/data-source.tf new file mode 100644 index 00000000..1a8d49de --- /dev/null +++ b/examples/data-sources/vercel_custom_environment/data-source.tf @@ -0,0 +1,8 @@ +data "vercel_project" "example" { + name = "example-project-with-custom-env" +} + +data "vercel_custom_environment" "example" { + project_id = data.vercel_project.example.id + name = "example-custom-env" +} diff --git a/examples/resources/vercel_custom_environment/import.sh b/examples/resources/vercel_custom_environment/import.sh new file mode 100644 index 00000000..b6f0718e --- /dev/null +++ b/examples/resources/vercel_custom_environment/import.sh @@ -0,0 +1,11 @@ +# If importing into a personal account, or with a team configured on +# the provider, simply use the project_id and custom environment name. +# - project_id can be found in the project `settings` tab in the Vercel UI. +terraform import vercel_custom_environment.example prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx/example-custom-env + +# Alternatively, you can 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. +# - project_id can be found in the project `settings` tab in the Vercel UI. +# +# Note also, that the value field for sensitive environment variables will be imported as `null`. +terraform import vercel_custom_environment.example team_xxxxxxxxxxxxxxxxxxxxxxxx/prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx/example-custom-env diff --git a/examples/resources/vercel_custom_environment/resource.tf b/examples/resources/vercel_custom_environment/resource.tf new file mode 100644 index 00000000..b7d55674 --- /dev/null +++ b/examples/resources/vercel_custom_environment/resource.tf @@ -0,0 +1,13 @@ +resource "vercel_project" "example" { + name = "example-project-with-custom-env" +} + +resource "vercel_custom_environment" "example" { + project_id = vercel_project.example.id + name = "example-custom-env" + description = "A description of the custom environment" + branch_tracking = { + pattern = "staging-" + type = "startsWith" + } +} diff --git a/vercel/data_source_custom_environment.go b/vercel/data_source_custom_environment.go new file mode 100644 index 00000000..038a933e --- /dev/null +++ b/vercel/data_source_custom_environment.go @@ -0,0 +1,134 @@ +package vercel + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/vercel/terraform-provider-vercel/v2/client" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &customEnvironmentDataSource{} +) + +func newCustomEnvironmentDataSource() datasource.DataSource { + return &customEnvironmentDataSource{} +} + +type customEnvironmentDataSource struct { + client *client.Client +} + +func (d *customEnvironmentDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_custom_environment" +} + +func (d *customEnvironmentDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + d.client = client +} + +// Schema returns the schema information for an customEnvironment data source +func (r *customEnvironmentDataSource) Schema(_ context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: ` +Provides information about an existing CustomEnvironment resource. + +An CustomEnvironment allows a ` + "`vercel_deployment` to be accessed through a different URL.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + Description: "The ID of the environment.", + }, + "team_id": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The team ID to add the project to. Required when configuring a team resource if a default team has not been set in the provider.", + }, + "project_id": schema.StringAttribute{ + Description: "The ID of the existing Vercel Project.", + Required: true, + }, + "name": schema.StringAttribute{ + Description: "The name of the environment.", + Required: true, + }, + "description": schema.StringAttribute{ + Description: "A description of what the environment is.", + Computed: true, + }, + "branch_tracking": schema.SingleNestedAttribute{ + Description: "The branch tracking configuration for the environment. When enabled, each qualifying merge will generate a deployment.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "pattern": schema.StringAttribute{ + Description: "The pattern of the branch name to track.", + Computed: true, + }, + "type": schema.StringAttribute{ + Description: "How a branch name should be matched against the pattern. Must be one of 'startsWith', 'endsWith' or 'equals'.", + Computed: true, + }, + }, + }, + }, + } +} + +// Read will read the customEnvironment information by requesting it from the Vercel API, and will update terraform +// with this information. +// It is called by the provider whenever data source values should be read to update state. +func (d *customEnvironmentDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config CustomEnvironment + diags := req.Config.Get(ctx, &config) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + res, err := d.client.GetCustomEnvironment(ctx, client.GetCustomEnvironmentRequest{ + TeamID: config.TeamID.ValueString(), + ProjectID: config.ProjectID.ValueString(), + Slug: config.Name.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError( + "Error reading custom environment", + fmt.Sprintf("Could not read custom environment %s %s %s, unexpected error: %s", + config.TeamID.ValueString(), + config.ProjectID.ValueString(), + config.Name.ValueString(), + err, + ), + ) + return + } + tflog.Trace(ctx, "read custom environment", map[string]interface{}{ + "team_id": config.TeamID.ValueString(), + "project_id": config.ProjectID.ValueString(), + "custom_environment_id": res.ID, + }) + + diags = resp.State.Set(ctx, convertResponseToModel(res)) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} diff --git a/vercel/data_source_custom_environment_test.go b/vercel/data_source_custom_environment_test.go new file mode 100644 index 00000000..e51b0324 --- /dev/null +++ b/vercel/data_source_custom_environment_test.go @@ -0,0 +1,57 @@ +package vercel_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAcc_CustomEnvironmentDataSource(t *testing.T) { + projectSuffix := acctest.RandString(16) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: testAccProjectDestroy("vercel_project.test", testTeam()), + Steps: []resource.TestStep{ + { + Config: testAccCustomEnvironmentDataSource(projectSuffix), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("data.vercel_custom_environment.test", "id"), + resource.TestCheckResourceAttrSet("data.vercel_custom_environment.test", "project_id"), + resource.TestCheckResourceAttrSet("data.vercel_custom_environment.test", "name"), + resource.TestCheckResourceAttr("data.vercel_custom_environment.test", "branch_tracking.type", "startsWith"), + resource.TestCheckResourceAttr("data.vercel_custom_environment.test", "branch_tracking.pattern", "staging-"), + resource.TestCheckResourceAttr("data.vercel_custom_environment.test", "description", "oh cool"), + ), + }, + }, + }) +} + +func testAccCustomEnvironmentDataSource(projectSuffix string) string { + return fmt.Sprintf(` +resource "vercel_project" "test" { + name = "test-acc-custom-env-data-source-%[1]s" + %[2]s +} + +resource "vercel_custom_environment" "test" { + project_id = vercel_project.test.id + %[2]s + name = "test-acc-ce-%[1]s" + description = "oh cool" + branch_tracking = { + pattern = "staging-" + type = "startsWith" + } +} + +data "vercel_custom_environment" "test" { + project_id = vercel_project.test.id + %[2]s + name = vercel_custom_environment.test.name +} +`, projectSuffix, teamIDConfig()) +} diff --git a/vercel/data_source_project.go b/vercel/data_source_project.go index c5ece7ee..3be3b0cf 100644 --- a/vercel/data_source_project.go +++ b/vercel/data_source_project.go @@ -104,6 +104,11 @@ For more detailed information, please see the [Vercel documentation](https://ver ElementType: types.StringType, Computed: true, }, + "custom_environment_ids": schema.SetAttribute{ + Description: "The IDs of Custom Environments that the Environment Variable should be present on.", + ElementType: types.StringType, + Computed: true, + }, "key": schema.StringAttribute{ Description: "The name of the environment variable.", Computed: true, diff --git a/vercel/provider.go b/vercel/provider.go index a0a54e17..8dcb97ec 100644 --- a/vercel/provider.go +++ b/vercel/provider.go @@ -54,6 +54,7 @@ func (p *vercelProvider) Resources(_ context.Context) []func() resource.Resource newAccessGroupResource, newAliasResource, newAttackChallengeModeResource, + newCustomEnvironmentResource, newDNSRecordResource, newDeploymentResource, newEdgeConfigItemResource, @@ -81,6 +82,7 @@ func (p *vercelProvider) DataSources(_ context.Context) []func() datasource.Data newAccessGroupProjectDataSource, newAliasDataSource, newAttackChallengeModeDataSource, + newCustomEnvironmentDataSource, newDeploymentDataSource, newEdgeConfigDataSource, newEdgeConfigItemDataSource, diff --git a/vercel/resource_custom_environment.go b/vercel/resource_custom_environment.go new file mode 100644 index 00000000..6554d5a2 --- /dev/null +++ b/vercel/resource_custom_environment.go @@ -0,0 +1,379 @@ +package vercel + +import ( + "context" + "fmt" + "regexp" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/vercel/terraform-provider-vercel/v2/client" +) + +var ( + _ resource.Resource = &customEnvironmentResource{} + _ resource.ResourceWithConfigure = &customEnvironmentResource{} + _ resource.ResourceWithImportState = &customEnvironmentResource{} +) + +func newCustomEnvironmentResource() resource.Resource { + return &customEnvironmentResource{} +} + +type customEnvironmentResource struct { + client *client.Client +} + +func (r *customEnvironmentResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_custom_environment" +} + +func (r *customEnvironmentResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.client = client +} + +func (r *customEnvironmentResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: ` +Environments help manage the deployment lifecycle on the Vercel platform. + +By default, all teams use three environments when developing their project: Production, Preview, and Development. However, teams can also create custom environments to suit their needs. To learn more about the limits for each plan, see limits. + +Custom environments allow you to configure customized, pre-production environments for your project, such as staging or QA, with branch rules that will automatically deploy your branch when the branch name matches the rule. With custom environments you can also attach a domain to your environment, set environment variables, or import environment variables from another environment. + +Custom environments are designed as pre-production environments intended for long-running use. This contrasts with regular preview environments, which are designed for creating ephemeral, short-lived deployments. +`, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + Description: "The ID of the environment.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "team_id": schema.StringAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplaceIfConfigured(), stringplanmodifier.UseStateForUnknown()}, + Description: "The team ID to add the project to. Required when configuring a team resource if a default team has not been set in the provider.", + }, + "project_id": schema.StringAttribute{ + Description: "The ID of the existing Vercel Project.", + Required: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "name": schema.StringAttribute{ + Description: "The name of the environment.", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 32), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[a-z0-9\-]{0,32}$`), + "The name of a Custom Environment can only contain up to 32 alphanumeric lowercase characters and hyphens", + ), + }, + }, + "description": schema.StringAttribute{ + Description: "A description of what the environment is.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "branch_tracking": schema.SingleNestedAttribute{ + Description: "The branch tracking configuration for the environment. When enabled, each qualifying merge will generate a deployment.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Object{objectplanmodifier.UseStateForUnknown()}, + Attributes: map[string]schema.Attribute{ + "pattern": schema.StringAttribute{ + Description: "The pattern of the branch name to track.", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 100), + }, + }, + "type": schema.StringAttribute{ + Description: "How a branch name should be matched against the pattern. Must be one of 'startsWith', 'endsWith' or 'equals'.", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf("startsWith", "endsWith", "equals"), + }, + }, + }, + }, + }, + } +} + +type BranchTracking struct { + Pattern types.String `tfsdk:"pattern"` + Type types.String `tfsdk:"type"` +} + +type CustomEnvironment struct { + ID types.String `tfsdk:"id"` + TeamID types.String `tfsdk:"team_id"` + ProjectID types.String `tfsdk:"project_id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + BranchTracking types.Object `tfsdk:"branch_tracking"` +} + +func (c CustomEnvironment) toCreateRequest(ctx context.Context) (client.CreateCustomEnvironmentRequest, diag.Diagnostics) { + var bm *client.BranchMatcher + if !c.BranchTracking.IsNull() && !c.BranchTracking.IsUnknown() { + bt, diags := c.branchTracking(ctx) + if diags.HasError() { + return client.CreateCustomEnvironmentRequest{}, diags + } + bm = &client.BranchMatcher{ + Pattern: bt.Pattern.ValueString(), + Type: bt.Type.ValueString(), + } + } + return client.CreateCustomEnvironmentRequest{ + TeamID: c.TeamID.ValueString(), + ProjectID: c.ProjectID.ValueString(), + Slug: c.Name.ValueString(), + Description: c.Description.ValueString(), + BranchMatcher: bm, + }, nil +} + +func (c CustomEnvironment) toUpdateRequest(ctx context.Context, name string) (client.UpdateCustomEnvironmentRequest, diag.Diagnostics) { + var bm *client.BranchMatcher + if !c.BranchTracking.IsNull() && !c.BranchTracking.IsUnknown() { + bt, diags := c.branchTracking(ctx) + if diags.HasError() { + return client.UpdateCustomEnvironmentRequest{}, diags + } + bm = &client.BranchMatcher{ + Pattern: bt.Pattern.ValueString(), + Type: bt.Type.ValueString(), + } + } + return client.UpdateCustomEnvironmentRequest{ + TeamID: c.TeamID.ValueString(), + ProjectID: c.ProjectID.ValueString(), + Slug: c.Name.ValueString(), // name is the slug + Description: c.Description.ValueString(), + BranchMatcher: bm, + OldSlug: name, + }, nil +} + +func (c CustomEnvironment) branchTracking(ctx context.Context) (BranchTracking, diag.Diagnostics) { + var bt BranchTracking + diags := c.BranchTracking.As(ctx, &bt, basetypes.ObjectAsOptions{ + UnhandledNullAsEmpty: false, + UnhandledUnknownAsEmpty: false, + }) + return bt, diags +} + +var branchTrackingAttrType = types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "pattern": types.StringType, + "type": types.StringType, + }, +} + +func convertResponseToModel(res client.CustomEnvironmentResponse) CustomEnvironment { + bt := types.ObjectNull(branchTrackingAttrType.AttrTypes) + if res.BranchMatcher != nil { + bt = types.ObjectValueMust( + branchTrackingAttrType.AttrTypes, map[string]attr.Value{ + "pattern": types.StringValue(res.BranchMatcher.Pattern), + "type": types.StringValue(res.BranchMatcher.Type), + }) + } + return CustomEnvironment{ + ID: types.StringValue(res.ID), + TeamID: types.StringValue(res.TeamID), + ProjectID: types.StringValue(res.ProjectID), + Name: types.StringValue(res.Slug), + Description: types.StringValue(res.Description), + BranchTracking: bt, + } +} + +func (r *customEnvironmentResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan CustomEnvironment + diags := req.Plan.Get(ctx, &plan) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + createRequest, diags := plan.toCreateRequest(ctx) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + res, err := r.client.CreateCustomEnvironment(ctx, createRequest) + if err != nil { + resp.Diagnostics.AddError( + "Error creating custom environment", + fmt.Sprintf("Could not create custom environment, unexpected error: %s", err), + ) + return + } + + tflog.Info(ctx, "created custom environment", map[string]interface{}{ + "team_id": plan.TeamID.ValueString(), + "project_id": plan.ProjectID.ValueString(), + "custom_environment_id": res.ID, + "total_res": res, + }) + + diags = resp.State.Set(ctx, convertResponseToModel(res)) + resp.Diagnostics.Append(diags...) +} + +func (r *customEnvironmentResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state CustomEnvironment + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + res, err := r.client.GetCustomEnvironment(ctx, client.GetCustomEnvironmentRequest{ + TeamID: state.TeamID.ValueString(), + ProjectID: state.ProjectID.ValueString(), + Slug: state.Name.ValueString(), + }) + if client.NotFound(err) { + resp.State.RemoveResource(ctx) + } + if err != nil { + resp.Diagnostics.AddError( + "Error reading custom environment", + fmt.Sprintf("Could not read custom environment, unexpected error: %s", err), + ) + return + } + tflog.Trace(ctx, "read custom environment", map[string]interface{}{ + "team_id": state.TeamID.ValueString(), + "project_id": state.ProjectID.ValueString(), + "custom_environment_id": res.ID, + }) + + diags = resp.State.Set(ctx, convertResponseToModel(res)) + resp.Diagnostics.Append(diags...) +} + +func (r *customEnvironmentResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan CustomEnvironment + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + var state CustomEnvironment + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + updateRequest, diags := plan.toUpdateRequest(ctx, state.Name.ValueString()) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + res, err := r.client.UpdateCustomEnvironment(ctx, updateRequest) + if err != nil { + resp.Diagnostics.AddError( + "Error updating custom environment", + fmt.Sprintf("Could not update custom environment, unexpected error: %s", err), + ) + return + } + + tflog.Trace(ctx, "created custom environment", map[string]interface{}{ + "team_id": plan.TeamID.ValueString(), + "project_id": plan.ProjectID.ValueString(), + "custom_environment_id": res.ID, + }) + + diags = resp.State.Set(ctx, convertResponseToModel(res)) + resp.Diagnostics.Append(diags...) +} + +func (r *customEnvironmentResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state CustomEnvironment + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteCustomEnvironment(ctx, client.DeleteCustomEnvironmentRequest{ + ProjectID: state.ProjectID.ValueString(), + TeamID: state.TeamID.ValueString(), + Slug: state.Name.ValueString(), + }) + if client.NotFound(err) { + resp.State.RemoveResource(ctx) + } + if err != nil { + resp.Diagnostics.AddError( + "Error removing custom environment", + fmt.Sprintf("Could not remove custom environment: %s", err), + ) + return + } +} + +// ImportState implements resource.ResourceWithImportState. +func (r *customEnvironmentResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + teamID, projectID, name, ok := splitInto2Or3(req.ID) + if !ok { + resp.Diagnostics.AddError( + "Error importing Custom Environment", + fmt.Sprintf("Invalid id '%s' specified. should be in format \"team_id/project_id/custom_environment_name\" or \"project_id/custom_environment_name\"", req.ID), + ) + } + res, err := r.client.GetCustomEnvironment(ctx, client.GetCustomEnvironmentRequest{ + TeamID: teamID, + ProjectID: projectID, + Slug: name, + }) + if client.NotFound(err) { + resp.State.RemoveResource(ctx) + } + if err != nil { + resp.Diagnostics.AddError( + "Error reading custom environment", + fmt.Sprintf("Could not read custom environment, unexpected error: %s", err), + ) + return + } + tflog.Trace(ctx, "import custom environment", map[string]interface{}{ + "team_id": teamID, + "project_id": projectID, + "custom_environment_id": res.ID, + }) + + diags := resp.State.Set(ctx, convertResponseToModel(res)) + resp.Diagnostics.Append(diags...) +} diff --git a/vercel/resource_custom_environment_test.go b/vercel/resource_custom_environment_test.go new file mode 100644 index 00000000..09d7388a --- /dev/null +++ b/vercel/resource_custom_environment_test.go @@ -0,0 +1,174 @@ +package vercel_test + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/vercel/terraform-provider-vercel/v2/client" +) + +func testCheckCustomEnvironmentExists(n 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") + } + + projectID := rs.Primary.Attributes["project_id"] + name := rs.Primary.Attributes["name"] + + _, err := testClient().GetCustomEnvironment(context.TODO(), client.GetCustomEnvironmentRequest{ + TeamID: testTeam(), + ProjectID: projectID, + Slug: name, + }) + if client.NotFound(err) { + return fmt.Errorf("test failed because the custom environment %s %s %s - %s could not be found", testTeam(), projectID, name, rs.Primary.ID) + } + return err + } +} + +func TestAcc_CustomEnvironmentResource(t *testing.T) { + projectSuffix := acctest.RandString(16) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: testAccProjectDestroy("vercel_project.test", testTeam()), + Steps: []resource.TestStep{ + { + Config: testAccCustomEnvironment(projectSuffix), + Check: resource.ComposeAggregateTestCheckFunc( + testCheckCustomEnvironmentExists("vercel_custom_environment.test"), + resource.TestCheckResourceAttrSet("vercel_custom_environment.test", "id"), + resource.TestCheckResourceAttrSet("vercel_custom_environment.test", "project_id"), + resource.TestCheckResourceAttrSet("vercel_custom_environment.test", "name"), + resource.TestCheckNoResourceAttr("vercel_custom_environment.test", "branch_tracking"), + resource.TestCheckResourceAttr("vercel_custom_environment.test", "description", "without branch tracking"), + + testCheckCustomEnvironmentExists("vercel_custom_environment.test_bt"), + resource.TestCheckResourceAttrSet("vercel_custom_environment.test_bt", "id"), + resource.TestCheckResourceAttrSet("vercel_custom_environment.test_bt", "project_id"), + resource.TestCheckResourceAttrSet("vercel_custom_environment.test_bt", "name"), + resource.TestCheckResourceAttr("vercel_custom_environment.test_bt", "branch_tracking.type", "startsWith"), + resource.TestCheckResourceAttr("vercel_custom_environment.test_bt", "branch_tracking.pattern", "staging-"), + resource.TestCheckResourceAttr("vercel_custom_environment.test_bt", "description", "with branch tracking"), + + // check project env var + resource.TestCheckResourceAttr("vercel_project_environment_variable.test", "custom_environment_ids.#", "1"), + + // check shared env var + resource.TestCheckResourceAttr("vercel_project_environment_variables.test", "variables.0.custom_environment_ids.#", "1"), + ), + }, + { + Config: testAccCustomEnvironmentUpdated(projectSuffix), + Check: resource.ComposeAggregateTestCheckFunc( + testCheckCustomEnvironmentExists("vercel_custom_environment.test"), + resource.TestCheckResourceAttrSet("vercel_custom_environment.test", "id"), + resource.TestCheckResourceAttrSet("vercel_custom_environment.test", "project_id"), + resource.TestCheckResourceAttrSet("vercel_custom_environment.test", "name"), + resource.TestCheckResourceAttr("vercel_custom_environment.test", "branch_tracking.type", "endsWith"), + resource.TestCheckResourceAttr("vercel_custom_environment.test", "branch_tracking.pattern", "staging-"), + resource.TestCheckResourceAttr("vercel_custom_environment.test", "description", "without branch tracking updated"), + ), + }, + { + ResourceName: "vercel_custom_environment.test", + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: getCustomEnvImportID("vercel_custom_environment.test"), + }, + }, + }) +} + +func getCustomEnvImportID(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") + } + + return fmt.Sprintf("%s/%s/%s", rs.Primary.Attributes["team_id"], rs.Primary.Attributes["project_id"], rs.Primary.Attributes["name"]), nil + } +} + +func testAccCustomEnvironment(projectSuffix string) string { + return fmt.Sprintf(` +resource "vercel_project" "test" { + name = "test-acc-custom-env-%[1]s" + %[2]s +} + +resource "vercel_custom_environment" "test" { + project_id = vercel_project.test.id + %[2]s + name = "test-acc-%[1]s" + description = "without branch tracking" +} + +// Ensure project_environment_variable works +resource "vercel_project_environment_variable" "test" { + project_id = vercel_project.test.id + %[2]s + key = "foo" + value = "test-acc-env-var" + custom_environment_ids = [vercel_custom_environment.test.id] +} + +// Ensure project_environment_variables works +resource "vercel_project_environment_variables" "test" { + project_id = vercel_project.test.id + %[2]s + variables = [{ + key = "bar" + value = "test-acc-env-var" + custom_environment_ids = [vercel_custom_environment.test.id] + }] +} + +resource "vercel_custom_environment" "test_bt" { + project_id = vercel_project.test.id + %[2]s + name = "test-acc-bt-%[1]s" + description = "with branch tracking" + branch_tracking = { + pattern = "staging-" + type = "startsWith" + } +} +`, projectSuffix, teamIDConfig()) +} + +func testAccCustomEnvironmentUpdated(projectSuffix string) string { + return fmt.Sprintf(` +resource "vercel_project" "test" { + name = "test-acc-custom-env-%[1]s" + %[2]s +} + +resource "vercel_custom_environment" "test" { + project_id = vercel_project.test.id + %[2]s + name = "test-acc-%[1]s-updtd" + description = "without branch tracking updated" + branch_tracking = { + pattern = "staging-" + type = "endsWith" + } +} +`, projectSuffix, teamIDConfig()) +} diff --git a/vercel/resource_project.go b/vercel/resource_project.go index f21251b6..944ae3c4 100644 --- a/vercel/resource_project.go +++ b/vercel/resource_project.go @@ -124,13 +124,37 @@ At this time you cannot use a Vercel Project resource with in-line ` + "`environ NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ "target": schema.SetAttribute{ - 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`. At least one of `target` or `custom_environment_ids` must be set.", ElementType: types.StringType, Validators: []validator.Set{ setvalidator.ValueStringsAre(stringvalidator.OneOf("production", "preview", "development")), setvalidator.SizeAtLeast(1), + setvalidator.AtLeastOneOf( + path.MatchRelative().AtParent().AtName("target"), + path.MatchRelative().AtParent().AtName("custom_environment_ids"), + ), + }, + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Set{ + setplanmodifier.UseStateForUnknown(), + }, + }, + "custom_environment_ids": schema.SetAttribute{ + Description: "The IDs of Custom Environments that the Environment Variable should be present on. At least one of `target` or `custom_environment_ids` must be set.", + ElementType: types.StringType, + Validators: []validator.Set{ + setvalidator.SizeAtLeast(1), + setvalidator.AtLeastOneOf( + path.MatchRelative().AtParent().AtName("target"), + path.MatchRelative().AtParent().AtName("custom_environment_ids"), + ), + }, + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Set{ + setplanmodifier.UseStateForUnknown(), }, - Required: true, }, "git_branch": schema.StringAttribute{ Description: "The git branch of the Environment Variable.", @@ -630,7 +654,7 @@ var nullProject = Project{ } func (p *Project) environment(ctx context.Context) ([]EnvironmentItem, error) { - if p.Environment.IsNull() { + if p.Environment.IsNull() || p.Environment.IsUnknown() { return nil, nil } @@ -642,16 +666,20 @@ func (p *Project) environment(ctx context.Context) ([]EnvironmentItem, error) { return vars, nil } -func parseEnvironment(vars []EnvironmentItem) []client.EnvironmentVariable { - out := []client.EnvironmentVariable{} +func parseEnvironment(ctx context.Context, vars []EnvironmentItem) (out []client.EnvironmentVariable, diags diag.Diagnostics) { for _, e := range vars { - target := []string{} - for _, t := range e.Target { - target = append(target, t.ValueString()) + var target []string + diags = e.Target.ElementsAs(ctx, &target, true) + if diags.HasError() { + return out, diags + } + var customEnvironmentIDs []string + diags = e.CustomEnvironmentIDs.ElementsAs(ctx, &customEnvironmentIDs, true) + if diags.HasError() { + return out, diags } var envVariableType string - if e.Sensitive.ValueBool() { envVariableType = "sensitive" } else { @@ -659,24 +687,26 @@ func parseEnvironment(vars []EnvironmentItem) []client.EnvironmentVariable { } out = append(out, client.EnvironmentVariable{ - Key: e.Key.ValueString(), - Value: e.Value.ValueString(), - Target: target, - GitBranch: e.GitBranch.ValueStringPointer(), - Type: envVariableType, - ID: e.ID.ValueString(), - Comment: e.Comment.ValueString(), + Key: e.Key.ValueString(), + Value: e.Value.ValueString(), + Target: target, + CustomEnvironmentIDs: customEnvironmentIDs, + GitBranch: e.GitBranch.ValueStringPointer(), + Type: envVariableType, + ID: e.ID.ValueString(), + Comment: e.Comment.ValueString(), }) } - return out + return out, nil } -func (p *Project) toCreateProjectRequest(envs []EnvironmentItem) client.CreateProjectRequest { +func (p *Project) toCreateProjectRequest(ctx context.Context, envs []EnvironmentItem) (req client.CreateProjectRequest, diags diag.Diagnostics) { + clientEnvs, diags := parseEnvironment(ctx, envs) return client.CreateProjectRequest{ BuildCommand: p.BuildCommand.ValueStringPointer(), CommandForIgnoringBuildStep: p.IgnoreCommand.ValueStringPointer(), DevCommand: p.DevCommand.ValueStringPointer(), - EnvironmentVariables: parseEnvironment(envs), + EnvironmentVariables: clientEnvs, Framework: p.Framework.ValueStringPointer(), GitRepository: p.GitRepository.toCreateProjectRequest(), InstallCommand: p.InstallCommand.ValueStringPointer(), @@ -686,7 +716,7 @@ func (p *Project) toCreateProjectRequest(envs []EnvironmentItem) client.CreatePr PublicSource: p.PublicSource.ValueBoolPointer(), RootDirectory: p.RootDirectory.ValueStringPointer(), ServerlessFunctionRegion: p.ServerlessFunctionRegion.ValueString(), - } + }, diags } func toSkewProtectionAge(sp types.String) int { @@ -749,23 +779,39 @@ func (p *Project) toUpdateProjectRequest(ctx context.Context, oldName string) (r // EnvironmentItem reflects the state terraform stores internally for a project's environment variable. type EnvironmentItem struct { - Target []types.String `tfsdk:"target"` - GitBranch types.String `tfsdk:"git_branch"` - Key types.String `tfsdk:"key"` - Value types.String `tfsdk:"value"` - ID types.String `tfsdk:"id"` - Sensitive types.Bool `tfsdk:"sensitive"` - Comment types.String `tfsdk:"comment"` + Target types.Set `tfsdk:"target"` + CustomEnvironmentIDs types.Set `tfsdk:"custom_environment_ids"` + GitBranch types.String `tfsdk:"git_branch"` + Key types.String `tfsdk:"key"` + Value types.String `tfsdk:"value"` + ID types.String `tfsdk:"id"` + Sensitive types.Bool `tfsdk:"sensitive"` + Comment types.String `tfsdk:"comment"` } -func (e *EnvironmentItem) toEnvironmentVariableRequest() client.EnvironmentVariableRequest { - target := []string{} - for _, t := range e.Target { - target = append(target, t.ValueString()) +func (e *EnvironmentItem) equal(other *EnvironmentItem) bool { + return e.Key.ValueString() == other.Key.ValueString() && + e.Value.ValueString() == other.Value.ValueString() && + e.Target.Equal(other.Target) && + e.CustomEnvironmentIDs.Equal(other.CustomEnvironmentIDs) && + e.GitBranch.ValueString() == other.GitBranch.ValueString() && + e.Sensitive.ValueBool() == other.Sensitive.ValueBool() && + e.Comment.ValueString() == other.Comment.ValueString() +} + +func (e *EnvironmentItem) toEnvironmentVariableRequest(ctx context.Context) (req client.EnvironmentVariableRequest, diags diag.Diagnostics) { + var target []string + diags = e.Target.ElementsAs(ctx, &target, true) + if diags.HasError() { + return req, diags + } + var customEnvironmentIDs []string + diags = e.CustomEnvironmentIDs.ElementsAs(ctx, &customEnvironmentIDs, true) + if diags.HasError() { + return req, diags } var envVariableType string - if e.Sensitive.ValueBool() { envVariableType = "sensitive" } else { @@ -773,13 +819,14 @@ func (e *EnvironmentItem) toEnvironmentVariableRequest() client.EnvironmentVaria } return client.EnvironmentVariableRequest{ - Key: e.Key.ValueString(), - Value: e.Value.ValueString(), - Target: target, - GitBranch: e.GitBranch.ValueStringPointer(), - Type: envVariableType, - Comment: e.Comment.ValueString(), - } + Key: e.Key.ValueString(), + Value: e.Value.ValueString(), + Target: target, + CustomEnvironmentIDs: customEnvironmentIDs, + GitBranch: e.GitBranch.ValueStringPointer(), + Type: envVariableType, + Comment: e.Comment.ValueString(), + }, nil } type DeployHook struct { @@ -1032,6 +1079,9 @@ var envVariableElemType = types.ObjectType{ "target": types.SetType{ ElemType: types.StringType, }, + "custom_environment_ids": types.SetType{ + ElemType: types.StringType, + }, "git_branch": types.StringType, "id": types.StringType, "sensitive": types.BoolType, @@ -1044,13 +1094,12 @@ var gitCommentsAttrTypes = map[string]attr.Type{ "on_pull_request": types.BoolType, } -func hasSameTarget(p EnvironmentItem, target []string) bool { - if len(p.Target) != len(target) { +func isSameStringSet(a []string, b []string) bool { + if len(a) != len(b) { return false } - for _, t := range p.Target { - v := t.ValueString() - if !contains(target, v) { + for _, v := range a { + if !contains(b, v) { return false } } @@ -1197,9 +1246,26 @@ func convertResponseToProject(ctx context.Context, response client.ProjectRespon var env []attr.Value for _, e := range environmentVariables { - target := []attr.Value{} - for _, t := range e.Target { - target = append(target, types.StringValue(t)) + var targetValue attr.Value + if len(e.Target) > 0 { + target := make([]attr.Value, 0, len(e.Target)) + for _, t := range e.Target { + target = append(target, types.StringValue(t)) + } + targetValue = types.SetValueMust(types.StringType, target) + } else { + targetValue = types.SetNull(types.StringType) + } + + var customEnvIDsValue attr.Value + if len(e.CustomEnvironmentIDs) > 0 { + customEnvIDs := make([]attr.Value, 0, len(e.CustomEnvironmentIDs)) + for _, c := range e.CustomEnvironmentIDs { + customEnvIDs = append(customEnvIDs, types.StringValue(c)) + } + customEnvIDsValue = types.SetValueMust(types.StringType, customEnvIDs) + } else { + customEnvIDsValue = types.SetNull(types.StringType) } value := types.StringValue(e.Value) if e.Type == "sensitive" { @@ -1209,8 +1275,17 @@ func convertResponseToProject(ctx context.Context, response client.ProjectRespon return Project{}, fmt.Errorf("error reading project environment variables: %s", err) } for _, p := range environment { - if p.Key.ValueString() == e.Key && hasSameTarget(p, e.Target) { - + var target []string + diags := p.Target.ElementsAs(ctx, &target, true) + if diags.HasError() { + return Project{}, fmt.Errorf("error reading project environment variables: %s", diags) + } + var customEnvironmentIDs []string + diags = p.CustomEnvironmentIDs.ElementsAs(ctx, &customEnvironmentIDs, true) + if diags.HasError() { + return Project{}, fmt.Errorf("error reading project environment variables: %s", diags) + } + if p.Key.ValueString() == e.Key && isSameStringSet(target, e.Target) && isSameStringSet(customEnvironmentIDs, e.CustomEnvironmentIDs) { value = p.Value break } @@ -1218,13 +1293,14 @@ func convertResponseToProject(ctx context.Context, response client.ProjectRespon } env = append(env, types.ObjectValueMust(envVariableElemType.AttrTypes, 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), + "key": types.StringValue(e.Key), + "value": value, + "target": targetValue, + "custom_environment_ids": customEnvIDsValue, + "git_branch": types.StringPointerValue(e.GitBranch), + "id": types.StringValue(e.ID), + "sensitive": types.BoolValue(e.Type == "sensitive"), + "comment": types.StringValue(e.Comment), })) } @@ -1383,7 +1459,12 @@ func (r *projectResource) Create(ctx context.Context, req resource.CreateRequest return } - out, err := r.client.CreateProject(ctx, plan.TeamID.ValueString(), plan.toCreateProjectRequest(environment)) + request, diags := plan.toCreateProjectRequest(ctx, environment) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + out, err := r.client.CreateProject(ctx, plan.TeamID.ValueString(), request) if err != nil { resp.Diagnostics.AddError( "Error creating project", @@ -1749,7 +1830,12 @@ func (r *projectResource) Update(ctx context.Context, req resource.UpdateRequest var items []client.EnvironmentVariableRequest for _, v := range toCreate { - items = append(items, v.toEnvironmentVariableRequest()) + vv, diags := v.toEnvironmentVariableRequest(ctx) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + items = append(items, vv) } if items != nil { diff --git a/vercel/resource_project_domain.go b/vercel/resource_project_domain.go index fda92518..98019d9f 100644 --- a/vercel/resource_project_domain.go +++ b/vercel/resource_project_domain.go @@ -5,6 +5,8 @@ import ( "fmt" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" @@ -94,6 +96,22 @@ By default, Project Domains will be automatically applied to any ` + "`productio "git_branch": schema.StringAttribute{ Description: "Git branch to link to the project domain. Deployments from this git branch will be assigned the domain name.", Optional: true, + Validators: []validator.String{ + stringvalidator.ConflictsWith( + path.MatchRoot("custom_environment_id"), + path.MatchRoot("git_branch"), + ), + }, + }, + "custom_environment_id": schema.StringAttribute{ + Description: "The name of the Custom Environment to link to the Project Domain. Deployments from this custom environment will be assigned the domain name.", + Optional: true, + Validators: []validator.String{ + stringvalidator.ConflictsWith( + path.MatchRoot("custom_environment_id"), + path.MatchRoot("git_branch"), + ), + }, }, }, } @@ -101,41 +119,45 @@ By default, Project Domains will be automatically applied to any ` + "`productio // ProjectDomain reflects the state terraform stores internally for a project domain. type ProjectDomain struct { - Domain types.String `tfsdk:"domain"` - GitBranch types.String `tfsdk:"git_branch"` - ID types.String `tfsdk:"id"` - ProjectID types.String `tfsdk:"project_id"` - Redirect types.String `tfsdk:"redirect"` - RedirectStatusCode types.Int64 `tfsdk:"redirect_status_code"` - TeamID types.String `tfsdk:"team_id"` + Domain types.String `tfsdk:"domain"` + GitBranch types.String `tfsdk:"git_branch"` + CustomEnvironmentID types.String `tfsdk:"custom_environment_id"` + ID types.String `tfsdk:"id"` + ProjectID types.String `tfsdk:"project_id"` + Redirect types.String `tfsdk:"redirect"` + RedirectStatusCode types.Int64 `tfsdk:"redirect_status_code"` + TeamID types.String `tfsdk:"team_id"` } func convertResponseToProjectDomain(response client.ProjectDomainResponse) ProjectDomain { return ProjectDomain{ - Domain: types.StringValue(response.Name), - GitBranch: types.StringPointerValue(response.GitBranch), - ID: types.StringValue(response.Name), - ProjectID: types.StringValue(response.ProjectID), - Redirect: types.StringPointerValue(response.Redirect), - RedirectStatusCode: types.Int64PointerValue(response.RedirectStatusCode), - TeamID: toTeamID(response.TeamID), + Domain: types.StringValue(response.Name), + GitBranch: types.StringPointerValue(response.GitBranch), + CustomEnvironmentID: types.StringPointerValue(response.CustomEnvironmentID), + ID: types.StringValue(response.Name), + ProjectID: types.StringValue(response.ProjectID), + Redirect: types.StringPointerValue(response.Redirect), + RedirectStatusCode: types.Int64PointerValue(response.RedirectStatusCode), + TeamID: toTeamID(response.TeamID), } } func (p *ProjectDomain) toCreateRequest() client.CreateProjectDomainRequest { return client.CreateProjectDomainRequest{ - GitBranch: p.GitBranch.ValueString(), - Name: p.Domain.ValueString(), - Redirect: p.Redirect.ValueString(), - RedirectStatusCode: p.RedirectStatusCode.ValueInt64(), + GitBranch: p.GitBranch.ValueString(), + CustomEnvironmentID: p.CustomEnvironmentID.ValueString(), + Name: p.Domain.ValueString(), + Redirect: p.Redirect.ValueString(), + RedirectStatusCode: p.RedirectStatusCode.ValueInt64(), } } func (p *ProjectDomain) toUpdateRequest() client.UpdateProjectDomainRequest { return client.UpdateProjectDomainRequest{ - GitBranch: p.GitBranch.ValueStringPointer(), - Redirect: p.Redirect.ValueStringPointer(), - RedirectStatusCode: p.RedirectStatusCode.ValueInt64Pointer(), + GitBranch: p.GitBranch.ValueStringPointer(), + CustomEnvironmentID: p.CustomEnvironmentID.ValueStringPointer(), + Redirect: p.Redirect.ValueStringPointer(), + RedirectStatusCode: p.RedirectStatusCode.ValueInt64Pointer(), } } diff --git a/vercel/resource_project_domain_test.go b/vercel/resource_project_domain_test.go index 288deea9..090f7167 100644 --- a/vercel/resource_project_domain_test.go +++ b/vercel/resource_project_domain_test.go @@ -14,7 +14,6 @@ import ( ) func TestAcc_ProjectDomain(t *testing.T) { - t.Skip() testTeamID := resource.TestCheckNoResourceAttr("vercel_project.test", "team_id") if testTeam() != "" { testTeamID = resource.TestCheckResourceAttr("vercel_project.test", "team_id", testTeam()) @@ -30,32 +29,32 @@ func TestAcc_ProjectDomain(t *testing.T) { Steps: []resource.TestStep{ // Check error adding production domain { - Config: testAccProjectDomainWithProductionGitBranch(projectSuffix, domain, teamIDConfig()), + Config: testAccProjectDomainWithProductionGitBranch(projectSuffix, "1"+domain, teamIDConfig()), ExpectError: regexp.MustCompile( strings.ReplaceAll("the git_branch specified is the production branch. If you want to use this domain as a production domain, please omit the git_branch field.", " ", `\s*`), ), }, // Create and Read testing { - Config: testAccProjectDomainConfig(projectSuffix, domain, teamIDConfig()), + Config: testAccProjectDomainConfig(projectSuffix, "2"+domain, teamIDConfig()), Check: resource.ComposeAggregateTestCheckFunc( - testAccProjectDomainExists("vercel_project.test", testTeam(), domain), + testAccProjectDomainExists("vercel_project.test", testTeam(), "2"+domain), testTeamID, - resource.TestCheckResourceAttr("vercel_project_domain.test", "domain", domain), + resource.TestCheckResourceAttr("vercel_project_domain.test", "domain", "2"+domain), ), }, // Update testing { - Config: testAccProjectDomainConfigUpdated(projectSuffix, domain, teamIDConfig()), + Config: testAccProjectDomainConfigUpdated(projectSuffix, "2"+domain, teamIDConfig()), Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("vercel_project_domain.test", "redirect", "test-acc-domain.vercel.app"), + resource.TestCheckResourceAttrSet("vercel_project_domain.test", "redirect"), ), }, // Redirect Update testing { - Config: testAccProjectDomainConfigUpdated2(projectSuffix, domain, teamIDConfig()), + Config: testAccProjectDomainConfigUpdated2(projectSuffix, "2"+domain, teamIDConfig()), Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("vercel_project_domain.test", "redirect", "test-acc-domain.vercel.app"), + resource.TestCheckResourceAttrSet("vercel_project_domain.test", "redirect"), resource.TestCheckResourceAttr("vercel_project_domain.test", "redirect_status_code", "307"), ), }, @@ -66,7 +65,30 @@ func TestAcc_ProjectDomain(t *testing.T) { }, }, }) +} +func TestAcc_ProjectDomainCustomEnvironment(t *testing.T) { + randomSuffix := acctest.RandString(16) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: noopDestroyCheck, + Steps: []resource.TestStep{ + // Ensure we can't have both git_branch and custom_environment_id + { + Config: testAccProjectDomainConfigWithCustomEnvironmentAndGitBranch(randomSuffix), + ExpectError: regexp.MustCompile( + strings.ReplaceAll("Attribute \"git_branch\" cannot be specified when \"custom_environment_id\" is specified", " ", `\s*`), + ), + }, + { + Config: testAccProjectDomainConfigWithCustomEnvironment(randomSuffix), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("vercel_project_domain.test", "custom_environment_id"), + ), + }, + }, + }) } func testAccProjectDomainExists(n, teamID, domain string) resource.TestCheckFunc { @@ -154,7 +176,13 @@ resource "vercel_project_domain" "test" { project_id = vercel_project.test.id %s - redirect = "test-acc-domain.vercel.app" + redirect = vercel_project_domain.redirect_target.domain +} + +resource "vercel_project_domain" "redirect_target" { + domain = "redirect-target-1-%[3]s" + project_id = vercel_project.test.id + %[2]s } `, projectSuffix, extra, domain, extra) } @@ -162,19 +190,31 @@ resource "vercel_project_domain" "test" { func testAccProjectDomainConfigUpdated2(projectSuffix, domain, extra string) string { return fmt.Sprintf(` resource "vercel_project" "test" { - name = "test-acc-domain-%s" - %s + name = "test-acc-domain-%[1]s" + %[2]s +} + +resource "vercel_project_domain" "redirect_target" { + domain = "redirect-target-1-%[3]s" + project_id = vercel_project.test.id + %[2]s +} + +resource "vercel_project_domain" "redirect_target_2" { + domain = "redirect-target-2-%[3]s" + project_id = vercel_project.test.id + %[2]s } resource "vercel_project_domain" "test" { - domain = "%s" + domain = "%[3]s" project_id = vercel_project.test.id - %s + %[2]s - redirect = "test-acc-domain.vercel.app" + redirect = vercel_project_domain.redirect_target_2.domain redirect_status_code = 307 } -`, projectSuffix, extra, domain, extra) +`, projectSuffix, extra, domain) } func testAccProjectDomainConfigDeleted(projectSuffix, extra string) string { @@ -185,3 +225,52 @@ resource "vercel_project" "test" { } `, projectSuffix, extra) } + +func testAccProjectDomainConfigWithCustomEnvironment(randomSuffix string) string { + return fmt.Sprintf(` +resource "vercel_project" "test" { + name = "test-acc-domain-%[1]s" + %[2]s +} + +resource "vercel_custom_environment" "test" { + name = "test-acc-custom-environment" + project_id = vercel_project.test.id + %[2]s +} + +resource "vercel_project_domain" "test" { + domain = "test-acc-domain-%[1]s-foobar.vercel.app" + project_id = vercel_project.test.id + custom_environment_id = vercel_custom_environment.test.id + %[2]s +} +`, randomSuffix, teamIDConfig()) +} + +func testAccProjectDomainConfigWithCustomEnvironmentAndGitBranch(randomSuffix string) string { + return fmt.Sprintf(` +resource "vercel_project" "test" { + name = "test-acc-domain-%[1]s" + %[2]s + git_repository = { + type = "github" + repo = "%[3]s" + } +} + +resource "vercel_custom_environment" "test" { + name = "test-acc-custom-environment" + project_id = vercel_project.test.id + %[2]s +} + +resource "vercel_project_domain" "test" { + domain = "test-acc-domain-%[1]s.vercel.app" + project_id = vercel_project.test.id + custom_environment_id = vercel_custom_environment.test.id + git_branch = "staging" + %[2]s +} +`, randomSuffix, teamIDConfig(), testGithubRepo()) +} diff --git a/vercel/resource_project_environment_variable.go b/vercel/resource_project_environment_variable.go index aaf7eaaf..7555b1ae 100644 --- a/vercel/resource_project_environment_variable.go +++ b/vercel/resource_project_environment_variable.go @@ -6,11 +6,14 @@ 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/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" @@ -70,12 +73,34 @@ At this time you cannot use a Vercel Project resource with in-line ` + "`environ `, Attributes: map[string]schema.Attribute{ "target": schema.SetAttribute{ - Required: true, - Description: "The environments that the Environment Variable should be present on. Valid targets are either `production`, `preview`, or `development`.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Set{ + setplanmodifier.UseStateForUnknown(), + }, + Description: "The environments that the Environment Variable should be present on. Valid targets are either `production`, `preview`, or `development`. At least one of `target` or `custom_environment_ids` must be set.", ElementType: types.StringType, Validators: []validator.Set{ setvalidator.ValueStringsAre(stringvalidator.OneOf("production", "preview", "development")), setvalidator.SizeAtLeast(1), + setvalidator.AtLeastOneOf( + path.MatchRoot("custom_environment_ids"), + path.MatchRoot("target"), + ), + }, + }, + "custom_environment_ids": schema.SetAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + Description: "The IDs of Custom Environments that the Environment Variable should be present on. At least one of `target` or `custom_environment_ids` must be set.", + PlanModifiers: []planmodifier.Set{setplanmodifier.RequiresReplace(), setplanmodifier.UseStateForUnknown()}, + Validators: []validator.Set{ + setvalidator.SizeAtLeast(1), + setvalidator.AtLeastOneOf( + path.MatchRoot("custom_environment_ids"), + path.MatchRoot("target"), + ), }, }, "key": schema.StringAttribute{ @@ -129,15 +154,16 @@ At this time you cannot use a Vercel Project resource with in-line ` + "`environ // 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"` - Sensitive types.Bool `tfsdk:"sensitive"` - Comment types.String `tfsdk:"comment"` + Target types.Set `tfsdk:"target"` + CustomEnvironmentIDs types.Set `tfsdk:"custom_environment_ids"` + 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"` + Sensitive types.Bool `tfsdk:"sensitive"` + Comment types.String `tfsdk:"comment"` } func (r *projectEnvironmentVariableResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { @@ -183,13 +209,18 @@ func (r *projectEnvironmentVariableResource) ModifyPlan(ctx context.Context, req ) } -func (e *ProjectEnvironmentVariable) toCreateEnvironmentVariableRequest() client.CreateEnvironmentVariableRequest { - target := []string{} - for _, t := range e.Target { - target = append(target, t.ValueString()) +func (e *ProjectEnvironmentVariable) toCreateEnvironmentVariableRequest(ctx context.Context) (req client.CreateEnvironmentVariableRequest, diags diag.Diagnostics) { + var target []string + diags = e.Target.ElementsAs(ctx, &target, true) + if diags.HasError() { + return req, diags + } + var customEnvironmentIDs []string + diags = e.CustomEnvironmentIDs.ElementsAs(ctx, &customEnvironmentIDs, true) + if diags.HasError() { + return req, diags } var envVariableType string - if e.Sensitive.ValueBool() { envVariableType = "sensitive" } else { @@ -198,26 +229,32 @@ func (e *ProjectEnvironmentVariable) toCreateEnvironmentVariableRequest() client return client.CreateEnvironmentVariableRequest{ EnvironmentVariable: client.EnvironmentVariableRequest{ - Key: e.Key.ValueString(), - Value: e.Value.ValueString(), - Target: target, - GitBranch: e.GitBranch.ValueStringPointer(), - Type: envVariableType, - Comment: e.Comment.ValueString(), + Key: e.Key.ValueString(), + Value: e.Value.ValueString(), + Target: target, + CustomEnvironmentIDs: customEnvironmentIDs, + GitBranch: e.GitBranch.ValueStringPointer(), + Type: envVariableType, + Comment: e.Comment.ValueString(), }, ProjectID: e.ProjectID.ValueString(), TeamID: e.TeamID.ValueString(), - } + }, nil } -func (e *ProjectEnvironmentVariable) toUpdateEnvironmentVariableRequest() client.UpdateEnvironmentVariableRequest { - target := []string{} - for _, t := range e.Target { - target = append(target, t.ValueString()) +func (e *ProjectEnvironmentVariable) toUpdateEnvironmentVariableRequest(ctx context.Context) (r client.UpdateEnvironmentVariableRequest, diags diag.Diagnostics) { + var target []string + diags = e.Target.ElementsAs(ctx, &target, true) + if diags.HasError() { + return r, diags + } + var customEnvironmentIDs []string + diags = e.CustomEnvironmentIDs.ElementsAs(ctx, &customEnvironmentIDs, true) + if diags.HasError() { + return r, diags } var envVariableType string - if e.Sensitive.ValueBool() { envVariableType = "sensitive" } else { @@ -225,22 +262,23 @@ func (e *ProjectEnvironmentVariable) toUpdateEnvironmentVariableRequest() client } 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(), - } + Value: e.Value.ValueString(), + Target: target, + CustomEnvironmentIDs: customEnvironmentIDs, + GitBranch: e.GitBranch.ValueStringPointer(), + Type: envVariableType, + ProjectID: e.ProjectID.ValueString(), + TeamID: e.TeamID.ValueString(), + EnvID: e.ID.ValueString(), + Comment: e.Comment.ValueString(), + }, nil } // 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, projectID types.String, v types.String) ProjectEnvironmentVariable { - target := []types.String{} + var target []attr.Value for _, t := range response.Target { target = append(target, types.StringValue(t)) } @@ -250,16 +288,22 @@ func convertResponseToProjectEnvironmentVariable(response client.EnvironmentVari value = v } + var customEnvironmentIDs []attr.Value + for _, c := range response.CustomEnvironmentIDs { + customEnvironmentIDs = append(customEnvironmentIDs, types.StringValue(c)) + } + return ProjectEnvironmentVariable{ - 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), + Target: types.SetValueMust(types.StringType, target), + CustomEnvironmentIDs: types.SetValueMust(types.StringType, customEnvironmentIDs), + 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), } } @@ -282,7 +326,12 @@ func (r *projectEnvironmentVariableResource) Create(ctx context.Context, req res return } - response, err := r.client.CreateEnvironmentVariable(ctx, plan.toCreateEnvironmentVariableRequest()) + request, diags := plan.toCreateEnvironmentVariableRequest(ctx) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + response, err := r.client.CreateEnvironmentVariable(ctx, request) if err != nil { resp.Diagnostics.AddError( "Error creating project environment variable", @@ -357,7 +406,12 @@ func (r *projectEnvironmentVariableResource) Update(ctx context.Context, req res return } - response, err := r.client.UpdateEnvironmentVariable(ctx, plan.toUpdateEnvironmentVariableRequest()) + request, diags := plan.toUpdateEnvironmentVariableRequest(ctx) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + response, err := r.client.UpdateEnvironmentVariable(ctx, request) if err != nil { resp.Diagnostics.AddError( "Error updating project environment variable", diff --git a/vercel/resource_project_environment_variables.go b/vercel/resource_project_environment_variables.go index 015ee57e..fb1a7cd3 100644 --- a/vercel/resource_project_environment_variables.go +++ b/vercel/resource_project_environment_variables.go @@ -7,11 +7,13 @@ 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/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" @@ -90,9 +92,8 @@ At this time you cannot use a Vercel Project resource with in-line ` + "`environ Computed: true, }, "key": schema.StringAttribute{ - Required: true, - PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, - Description: "The name of the Environment Variable.", + Required: true, + Description: "The name of the Environment Variable.", }, "value": schema.StringAttribute{ Required: true, @@ -100,12 +101,32 @@ At this time you cannot use a Vercel Project resource with in-line ` + "`environ // Sensitive: true, }, "target": schema.SetAttribute{ - Required: true, - Description: "The environments that the Environment Variable should be present on. Valid targets are either `production`, `preview`, or `development`.", - ElementType: types.StringType, + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Set{setplanmodifier.UseStateForUnknown()}, + Description: "The environments that the Environment Variable should be present on. Valid targets are either `production`, `preview`, or `development`. At least one of `target` or `custom_environment_ids` must be set.", + ElementType: types.StringType, Validators: []validator.Set{ setvalidator.ValueStringsAre(stringvalidator.OneOf("production", "preview", "development")), setvalidator.SizeAtLeast(1), + setvalidator.AtLeastOneOf( + path.MatchRelative().AtParent().AtName("custom_environment_ids"), + path.MatchRelative().AtParent().AtName("target"), + ), + }, + }, + "custom_environment_ids": schema.SetAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + PlanModifiers: []planmodifier.Set{setplanmodifier.UseStateForUnknown()}, + Description: "The IDs of Custom Environments that the Environment Variable should be present on. At least one of `target` or `custom_environment_ids` must be set.", + Validators: []validator.Set{ + setvalidator.SizeAtLeast(1), + setvalidator.AtLeastOneOf( + path.MatchRelative().AtParent().AtName("custom_environment_ids"), + path.MatchRelative().AtParent().AtName("target"), + ), }, }, "git_branch": schema.StringAttribute{ @@ -116,7 +137,7 @@ At this time you cannot use a Vercel Project resource with in-line ` + "`environ Description: "Whether the Environment Variable is sensitive or not.", Optional: true, Computed: true, - PlanModifiers: []planmodifier.Bool{boolplanmodifier.RequiresReplace()}, + PlanModifiers: []planmodifier.Bool{boolplanmodifier.UseStateForUnknown()}, }, "comment": schema.StringAttribute{ Description: "A comment explaining what the environment variable is for.", @@ -140,17 +161,14 @@ type ProjectEnvironmentVariables struct { Variables types.Set `tfsdk:"variables"` } -func (p *ProjectEnvironmentVariables) environment(ctx context.Context) ([]EnvironmentItem, error) { +func (p *ProjectEnvironmentVariables) environment(ctx context.Context) ([]EnvironmentItem, diag.Diagnostics) { if p.Variables.IsNull() { return nil, nil } var vars []EnvironmentItem - err := p.Variables.ElementsAs(ctx, &vars, true) - if err != nil { - return nil, fmt.Errorf("error reading project environment variables: %s", err) - } - return vars, nil + diags := p.Variables.ElementsAs(ctx, &vars, true) + return vars, diags } func (r *projectEnvironmentVariablesResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { @@ -164,12 +182,9 @@ func (r *projectEnvironmentVariablesResource) ModifyPlan(ctx context.Context, re return } - environment, err := config.environment(ctx) - if err != nil { - resp.Diagnostics.AddError( - "Error parsing project environment variables", - "Could not read environment variables, unexpected error: "+err.Error(), - ) + environment, diags := config.environment(ctx) + if diags.HasError() { + resp.Diagnostics.Append(diags...) return } @@ -224,17 +239,23 @@ func (r *projectEnvironmentVariablesResource) ModifyPlan(ctx context.Context, re } } -func (e *ProjectEnvironmentVariables) toCreateEnvironmentVariablesRequest(ctx context.Context) (r client.CreateEnvironmentVariablesRequest, err error) { - envs, err := e.environment(ctx) - if err != nil { - return r, err +func (e *ProjectEnvironmentVariables) toCreateEnvironmentVariablesRequest(ctx context.Context) (r client.CreateEnvironmentVariablesRequest, diags diag.Diagnostics) { + envs, diags := e.environment(ctx) + if diags.HasError() { + return r, diags } variables := []client.EnvironmentVariableRequest{} for _, e := range envs { - target := []string{} - for _, t := range e.Target { - target = append(target, t.ValueString()) + var target []string + diags = e.Target.ElementsAs(ctx, &target, true) + if diags.HasError() { + return r, diags + } + var customEnvironmentIDs []string + diags = e.CustomEnvironmentIDs.ElementsAs(ctx, &customEnvironmentIDs, true) + if diags.HasError() { + return r, diags } var envVariableType string if e.Sensitive.ValueBool() { @@ -243,12 +264,13 @@ func (e *ProjectEnvironmentVariables) toCreateEnvironmentVariablesRequest(ctx co envVariableType = "encrypted" } variables = append(variables, client.EnvironmentVariableRequest{ - Key: e.Key.ValueString(), - Value: e.Value.ValueString(), - Target: target, - Type: envVariableType, - GitBranch: e.GitBranch.ValueStringPointer(), - Comment: e.Comment.ValueString(), + Key: e.Key.ValueString(), + Value: e.Value.ValueString(), + Target: target, + CustomEnvironmentIDs: customEnvironmentIDs, + Type: envVariableType, + GitBranch: e.GitBranch.ValueStringPointer(), + Comment: e.Comment.ValueString(), }) } @@ -262,17 +284,35 @@ func (e *ProjectEnvironmentVariables) toCreateEnvironmentVariablesRequest(ctx co // 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(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) +func convertResponseToProjectEnvironmentVariables(ctx context.Context, response []client.EnvironmentVariable, plan ProjectEnvironmentVariables) (ProjectEnvironmentVariables, diag.Diagnostics) { + environment, diags := plan.environment(ctx) + if diags.HasError() { + return ProjectEnvironmentVariables{}, diags } var env []attr.Value + alreadyPresent := map[string]struct{}{} for _, e := range response { - target := []attr.Value{} - for _, t := range e.Target { - target = append(target, types.StringValue(t)) + var targetValue attr.Value + if len(e.Target) > 0 { + target := make([]attr.Value, 0, len(e.Target)) + for _, t := range e.Target { + target = append(target, types.StringValue(t)) + } + targetValue = types.SetValueMust(types.StringType, target) + } else { + targetValue = types.SetNull(types.StringType) + } + + var customEnvIDsValue attr.Value + if len(e.CustomEnvironmentIDs) > 0 { + customEnvIDs := make([]attr.Value, 0, len(e.CustomEnvironmentIDs)) + for _, c := range e.CustomEnvironmentIDs { + customEnvIDs = append(customEnvIDs, types.StringValue(c)) + } + customEnvIDsValue = types.SetValueMust(types.StringType, customEnvIDs) + } else { + customEnvIDsValue = types.SetNull(types.StringType) } value := types.StringValue(e.Value) if e.Type == "sensitive" { @@ -280,13 +320,29 @@ func convertResponseToProjectEnvironmentVariables(ctx context.Context, response } if !e.Decrypted || e.Type == "sensitive" { for _, p := range environment { - if p.Key.ValueString() == e.Key && hasSameTarget(p, e.Target) { + var target []string + diags := p.Target.ElementsAs(ctx, &target, true) + if diags.HasError() { + return ProjectEnvironmentVariables{}, diags + } + var customEnvironmentIDs []string + diags = p.CustomEnvironmentIDs.ElementsAs(ctx, &customEnvironmentIDs, true) + if diags.HasError() { + return ProjectEnvironmentVariables{}, diags + } + if p.Key.ValueString() == e.Key && isSameStringSet(target, e.Target) && isSameStringSet(customEnvironmentIDs, e.CustomEnvironmentIDs) { value = p.Value break } } } + // The Vercel API returns duplicate environment variables, so we need to filter them out. + if _, ok := alreadyPresent[e.ID]; ok { + continue + } + alreadyPresent[e.ID] = struct{}{} + env = append(env, types.ObjectValueMust( map[string]attr.Type{ "key": types.StringType, @@ -294,19 +350,23 @@ func convertResponseToProjectEnvironmentVariables(ctx context.Context, response "target": types.SetType{ ElemType: types.StringType, }, + "custom_environment_ids": 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), + "key": types.StringValue(e.Key), + "value": value, + "target": targetValue, + "custom_environment_ids": customEnvIDsValue, + "git_branch": types.StringPointerValue(e.GitBranch), + "id": types.StringValue(e.ID), + "sensitive": types.BoolValue(e.Type == "sensitive"), + "comment": types.StringValue(e.Comment), }, )) } @@ -337,12 +397,9 @@ func (r *projectEnvironmentVariablesResource) Create(ctx context.Context, req re return } - 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(), - ) + request, diags := plan.toCreateEnvironmentVariablesRequest(ctx) + if diags.HasError() { + resp.Diagnostics.Append(diags...) return } created, err := r.client.CreateEnvironmentVariables(ctx, request) @@ -353,12 +410,9 @@ func (r *projectEnvironmentVariablesResource) Create(ctx context.Context, req re ) } - 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(), - ) + result, diags := convertResponseToProjectEnvironmentVariables(ctx, created, plan) + if diags.HasError() { + resp.Diagnostics.Append(diags...) return } @@ -385,12 +439,9 @@ func (r *projectEnvironmentVariablesResource) Read(ctx context.Context, req reso return } - 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(), - ) + existing, diags := state.environment(ctx) + if diags.HasError() { + resp.Diagnostics.Append(diags...) return } existingIDs := map[string]struct{}{} @@ -424,12 +475,9 @@ func (r *projectEnvironmentVariablesResource) Read(ctx context.Context, req reso } } - 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(), - ) + result, diags := convertResponseToProjectEnvironmentVariables(ctx, toUse, state) + if diags.HasError() { + resp.Diagnostics.Append(diags...) return } @@ -461,36 +509,42 @@ func (r *projectEnvironmentVariablesResource) Update(ctx context.Context, req re 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(), - ) + stateEnvs, diags := state.environment(ctx) + if diags.HasError() { + resp.Diagnostics.Append(diags...) 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(), - ) + planEnvs, diags := plan.environment(ctx) + if diags.HasError() { + resp.Diagnostics.Append(diags...) return } - plannedIDs := map[string]struct{}{} + plannedEnvsByID := map[string]EnvironmentItem{} + toAdd := []EnvironmentItem{} for _, e := range planEnvs { - if e.ID.ValueString() == "" { - plannedIDs[e.ID.ValueString()] = struct{}{} + if e.ID.ValueString() != "" { + plannedEnvsByID[e.ID.ValueString()] = e + } else { + toAdd = append(toAdd, e) } } var toRemove []EnvironmentItem for _, e := range stateEnvs { - if _, ok := plannedIDs[e.ID.ValueString()]; !ok { + plannedEnv, ok := plannedEnvsByID[e.ID.ValueString()] + if !ok { + toRemove = append(toRemove, e) + continue + } + if !plannedEnv.equal(&e) { toRemove = append(toRemove, e) + toAdd = append(toAdd, plannedEnv) } } + tflog.Debug(ctx, "Removing environment variables", map[string]interface{}{"to_remove": toRemove}) + tflog.Debug(ctx, "Adding environment variables", map[string]interface{}{"to_add": toAdd}) + for _, v := range toRemove { err := r.client.DeleteEnvironmentVariable(ctx, state.ProjectID.ValueString(), state.TeamID.ValueString(), v.ID.ValueString()) if err != nil { @@ -512,29 +566,31 @@ func (r *projectEnvironmentVariablesResource) Update(ctx context.Context, req re }) } - 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 variables", - "Could not update project environment variable, unexpected error: "+err.Error(), - ) - return + var response []client.EnvironmentVariable + var err error + if len(toAdd) > 0 { + request, diags := plan.toCreateEnvironmentVariablesRequest(ctx) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + tflog.Debug(ctx, "create request", map[string]any{ + "request": request, + }) + response, err = r.client.CreateEnvironmentVariables(ctx, request) + if err != nil { + resp.Diagnostics.AddError( + "Error updating project environment variables", + "Could not update project environment variable, unexpected error: "+err.Error(), + ) + return + } + } else { } - 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(), - ) + result, diags := convertResponseToProjectEnvironmentVariables(ctx, response, plan) + if diags.HasError() { + resp.Diagnostics.Append(diags...) return } @@ -559,12 +615,9 @@ func (r *projectEnvironmentVariablesResource) Delete(ctx context.Context, req re return } - envs, err := state.environment(ctx) - if err != nil { - resp.Diagnostics.AddError( - "Error parsing project environment variables", - "Could not read environment variables, unexpected error: "+err.Error(), - ) + envs, diags := state.environment(ctx) + if diags.HasError() { + resp.Diagnostics.Append(diags...) return } for _, v := range envs { diff --git a/vercel/resource_project_members.go b/vercel/resource_project_members.go index 6ad2d5ee..e6a3ed82 100644 --- a/vercel/resource_project_members.go +++ b/vercel/resource_project_members.go @@ -90,6 +90,7 @@ This, however, means config drift will not be detected for members that are adde }, Validators: []validator.String{ stringvalidator.ExactlyOneOf( + path.MatchRelative().AtParent().AtName("user_id"), path.MatchRelative().AtParent().AtName("email"), path.MatchRelative().AtParent().AtName("username"), ), @@ -105,6 +106,7 @@ This, however, means config drift will not be detected for members that are adde Validators: []validator.String{ stringvalidator.ExactlyOneOf( path.MatchRelative().AtParent().AtName("user_id"), + path.MatchRelative().AtParent().AtName("email"), path.MatchRelative().AtParent().AtName("username"), ), }, @@ -120,6 +122,7 @@ This, however, means config drift will not be detected for members that are adde stringvalidator.ExactlyOneOf( path.MatchRelative().AtParent().AtName("user_id"), path.MatchRelative().AtParent().AtName("email"), + path.MatchRelative().AtParent().AtName("username"), ), }, }, diff --git a/vercel/resource_team_config.go b/vercel/resource_team_config.go index 979feca6..6625946d 100644 --- a/vercel/resource_team_config.go +++ b/vercel/resource_team_config.go @@ -131,6 +131,7 @@ func (r *teamConfigResource) Schema(_ context.Context, req resource.SchemaReques Validators: []validator.Map{ // Validate only this attribute or roles is configured. mapvalidator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("saml.roles"), path.MatchRoot("saml.access_group_id"), }...), }, @@ -143,6 +144,7 @@ func (r *teamConfigResource) Schema(_ context.Context, req resource.SchemaReques // Validate only this attribute or roles is configured. stringvalidator.ExactlyOneOf(path.Expressions{ path.MatchRoot("saml.roles"), + path.MatchRoot("saml.access_group_id"), }...), }, },