diff --git a/.gitignore b/.gitignore index ad605acc..badc2056 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ terraform-provider-vercel .task/ Dist/ +.DS_Store diff --git a/client/alias_create.go b/client/alias_create.go new file mode 100644 index 00000000..75044b04 --- /dev/null +++ b/client/alias_create.go @@ -0,0 +1,55 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// CreateAliasRequest defines the request the Vercel API expects in order to create an alias. +type CreateAliasRequest struct { + Alias string `json:"alias"` +} + +// The create Alias endpoint does not return the full AliasResponse, only the UID and Alias. +type createAliasResponse struct { + UID string `json:"uid"` + Alias string `json:"alias"` +} + +// CreateAlias creates an alias within Vercel. +func (c *Client) CreateAlias(ctx context.Context, request CreateAliasRequest, deploymentID string, teamID string) (r AliasResponse, err error) { + url := fmt.Sprintf("%s/v2/deployments/%s/aliases", c.baseURL, deploymentID) + if teamID != "" { + url = fmt.Sprintf("%s?teamId=%s", url, teamID) + } + payload := string(mustMarshal(request)) + req, err := http.NewRequestWithContext( + ctx, + "POST", + url, + strings.NewReader(payload), + ) + if err != nil { + return r, err + } + + tflog.Trace(ctx, "creating alias", map[string]interface{}{ + "url": url, + "payload": payload, + }) + var aliasResponse createAliasResponse + err = c.doRequest(req, &aliasResponse) + if err != nil { + return r, err + } + + return AliasResponse{ + UID: aliasResponse.UID, + Alias: aliasResponse.Alias, + DeploymentID: deploymentID, + }, nil +} diff --git a/client/alias_delete.go b/client/alias_delete.go new file mode 100644 index 00000000..a0dbfecb --- /dev/null +++ b/client/alias_delete.go @@ -0,0 +1,36 @@ +package client + +import ( + "context" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// DeleteAliasResponse defines the response the Vercel API returns when an alias is deleted. +type DeleteAliasResponse struct { + Status string `json:"status"` +} + +// DeleteAlias deletes an alias within Vercel. +func (c *Client) DeleteAlias(ctx context.Context, aliasUID string, teamID string) (r DeleteAliasResponse, err error) { + url := fmt.Sprintf("%s/v2/aliases/%s", c.baseURL, aliasUID) + if teamID != "" { + url = fmt.Sprintf("%s?teamId=%s", url, teamID) + } + req, err := http.NewRequest( + "DELETE", + url, + nil, + ) + if err != nil { + return r, err + } + + tflog.Trace(ctx, "deleting alias", map[string]interface{}{ + "url": url, + }) + err = c.doRequest(req, &r) + return r, err +} diff --git a/client/alias_get.go b/client/alias_get.go new file mode 100644 index 00000000..80a57bdf --- /dev/null +++ b/client/alias_get.go @@ -0,0 +1,38 @@ +package client + +import ( + "context" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// AliasResponse defines the response the Vercel API returns for an alias. +type AliasResponse struct { + UID string `json:"uid"` + Alias string `json:"alias"` + DeploymentID string `json:"deploymentId"` +} + +// GetAlias retrieves information about an existing alias from vercel. +func (c *Client) GetAlias(ctx context.Context, alias, teamID string) (r AliasResponse, err error) { + url := fmt.Sprintf("%s/v4/aliases/%s", c.baseURL, alias) + if teamID != "" { + url = fmt.Sprintf("%s?teamId=%s", url, teamID) + } + req, err := http.NewRequestWithContext( + ctx, + "GET", + url, + nil, + ) + if err != nil { + return r, fmt.Errorf("creating request: %s", err) + } + tflog.Trace(ctx, "getting alias", map[string]interface{}{ + "url": url, + }) + err = c.doRequest(req, &r) + return r, err +} diff --git a/client/deployment_delete.go b/client/deployment_delete.go index a53c8cad..f947257a 100644 --- a/client/deployment_delete.go +++ b/client/deployment_delete.go @@ -17,6 +17,9 @@ type DeleteDeploymentResponse struct { // DeleteDeployment deletes a deployment within Vercel. func (c *Client) DeleteDeployment(ctx context.Context, deploymentID string, teamID string) (r DeleteDeploymentResponse, err error) { url := fmt.Sprintf("%s/v13/deployments/%s", c.baseURL, deploymentID) + if teamID != "" { + url = fmt.Sprintf("%s?teamId=%s", url, teamID) + } req, err := http.NewRequest( "DELETE", url, @@ -26,20 +29,9 @@ func (c *Client) DeleteDeployment(ctx context.Context, deploymentID string, team return r, err } - // Add query parameters - q := req.URL.Query() - if teamID != "" { - q.Add("teamId", teamID) - } - req.URL.RawQuery = q.Encode() - tflog.Trace(ctx, "deleting deployment", map[string]interface{}{ "url": url, }) err = c.doRequest(req, &r) - if err != nil { - return r, err - } - - return r, nil + return r, err } diff --git a/client/error.go b/client/error.go new file mode 100644 index 00000000..688fdcb3 --- /dev/null +++ b/client/error.go @@ -0,0 +1,9 @@ +package client + +import "errors" + +// NotFound detects if an error returned by the Vercel API was the result of an entity not existing. +func NotFound(err error) bool { + var apiErr APIError + return err != nil && errors.As(err, &apiErr) && apiErr.StatusCode == 404 +} diff --git a/client/request.go b/client/request.go index 1a13f505..f341aec9 100644 --- a/client/request.go +++ b/client/request.go @@ -45,13 +45,17 @@ func (c *Client) doRequest(req *http.Request, v interface{}) error { if resp.StatusCode >= 300 { var errorResponse APIError + if string(responseBody) == "" { + errorResponse.StatusCode = resp.StatusCode + return errorResponse + } err = json.Unmarshal(responseBody, &struct { Error *APIError `json:"error"` }{ Error: &errorResponse, }) if err != nil { - return fmt.Errorf("error unmarshaling response: %w", err) + return fmt.Errorf("error unmarshaling response for status code %d: %w", resp.StatusCode, err) } errorResponse.StatusCode = resp.StatusCode errorResponse.RawMessage = responseBody diff --git a/docs/data-sources/alias.md b/docs/data-sources/alias.md new file mode 100644 index 00000000..18fc56bc --- /dev/null +++ b/docs/data-sources/alias.md @@ -0,0 +1,34 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_alias Data Source - terraform-provider-vercel" +subcategory: "" +description: |- + Provides information about an existing Alias resource. + An Alias allows a vercel_deployment to be accessed through a different URL. +--- + +# vercel_alias (Data Source) + +Provides information about an existing Alias resource. + +An Alias allows a `vercel_deployment` to be accessed through a different URL. + + + + +## Schema + +### Required + +- `alias` (String) The Alias or Alias ID to be retrieved. + +### Optional + +- `team_id` (String) The ID of the team the Alias and Deployment exist under. + +### Read-Only + +- `deployment_id` (String) The ID of the Deployment the Alias is associated with. +- `id` (String) The ID of this resource. + + diff --git a/docs/data-sources/project.md b/docs/data-sources/project.md index f19e57fc..0933b3da 100644 --- a/docs/data-sources/project.md +++ b/docs/data-sources/project.md @@ -48,10 +48,12 @@ output "project_id" { - `framework` (String) The framework that is being used for this project. If omitted, no framework is selected. - `git_repository` (Attributes) The Git Repository that will be connected to the project. When this is defined, any pushes to the specified connected Git Repository will be automatically deployed. This requires the corresponding Vercel for [Github](https://vercel.com/docs/concepts/git/vercel-for-github), [Gitlab](https://vercel.com/docs/concepts/git/vercel-for-gitlab) or [Bitbucket](https://vercel.com/docs/concepts/git/vercel-for-bitbucket) plugins to be installed. (see [below for nested schema](#nestedatt--git_repository)) - `id` (String) The ID of this resource. +- `ignore_command` (String) When a commit is pushed to the Git repository that is connected with your Project, its SHA will determine if a new Build has to be issued. If the SHA was deployed before, no new Build will be issued. You can customize this behavior with a command that exits with code 1 (new Build needed) or code 0. - `install_command` (String) The install command for this project. If omitted, this value will be automatically detected. - `output_directory` (String) The output directory of the project. When null is used this value will be automatically detected. - `public_source` (Boolean) Specifies whether the source code and logs of the deployments for this project should be public or not. - `root_directory` (String) The name of a directory or relative path to the source code of your project. When null is used it will default to the project root. +- `serverless_function_region` (String) The region on Vercel's network to which your Serverless Functions are deployed. It should be close to any data source your Serverless Function might depend on. A new Deployment is required for your changes to take effect. Please see [Vercel's documentation](https://vercel.com/docs/concepts/edge-network/regions) for a full list of regions. ### Nested Schema for `environment` diff --git a/docs/resources/alias.md b/docs/resources/alias.md new file mode 100644 index 00000000..359ee8be --- /dev/null +++ b/docs/resources/alias.md @@ -0,0 +1,34 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_alias Resource - terraform-provider-vercel" +subcategory: "" +description: |- + Provides an Alias resource. + An Alias allows a vercel_deployment to be accessed through a different URL. +--- + +# vercel_alias (Resource) + +Provides an Alias resource. + +An Alias allows a `vercel_deployment` to be accessed through a different URL. + + + + +## Schema + +### Required + +- `alias` (String) The Alias we want to assign to the deployment defined in the URL. +- `deployment_id` (String) The id of the Deployment the Alias should be associated with. + +### Optional + +- `team_id` (String) The ID of the team the Alias and Deployment exist under. + +### Read-Only + +- `id` (String) The ID of this resource. + + diff --git a/docs/resources/project.md b/docs/resources/project.md index 74ebfc91..ad9fcc2c 100644 --- a/docs/resources/project.md +++ b/docs/resources/project.md @@ -71,10 +71,12 @@ resource "vercel_project" "example" { - `environment` (Attributes Set) A set of environment variables that should be configured for the project. (see [below for nested schema](#nestedatt--environment)) - `framework` (String) The framework that is being used for this project. If omitted, no framework is selected. - `git_repository` (Attributes) The Git Repository that will be connected to the project. When this is defined, any pushes to the specified connected Git Repository will be automatically deployed. This requires the corresponding Vercel for [Github](https://vercel.com/docs/concepts/git/vercel-for-github), [Gitlab](https://vercel.com/docs/concepts/git/vercel-for-gitlab) or [Bitbucket](https://vercel.com/docs/concepts/git/vercel-for-bitbucket) plugins to be installed. (see [below for nested schema](#nestedatt--git_repository)) +- `ignore_command` (String) When a commit is pushed to the Git repository that is connected with your Project, its SHA will determine if a new Build has to be issued. If the SHA was deployed before, no new Build will be issued. You can customize this behavior with a command that exits with code 1 (new Build needed) or code 0. - `install_command` (String) The install command for this project. If omitted, this value will be automatically detected. -- `output_directory` (String) The output directory of the project. When null is used this value will be automatically detected. +- `output_directory` (String) The output directory of the project. If omitted, this value will be automatically detected. - `public_source` (Boolean) Specifies whether the source code and logs of the deployments for this project should be public or not. -- `root_directory` (String) The name of a directory or relative path to the source code of your project. When null is used it will default to the project root. +- `root_directory` (String) The name of a directory or relative path to the source code of your project. If omitted, it will default to the project root. +- `serverless_function_region` (String) The region on Vercel's network to which your Serverless Functions are deployed. It should be close to any data source your Serverless Function might depend on. A new Deployment is required for your changes to take effect. Please see [Vercel's documentation](https://vercel.com/docs/concepts/edge-network/regions) for a full list of regions. - `team_id` (String) The team ID to add the project to. ### Read-Only diff --git a/vercel/data_source_alias.go b/vercel/data_source_alias.go new file mode 100644 index 00000000..791d862e --- /dev/null +++ b/vercel/data_source_alias.go @@ -0,0 +1,93 @@ +package vercel + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +type dataSourceAliasType struct{} + +// GetSchema returns the schema information for an alias data source +func (r dataSourceAliasType) GetSchema(_ context.Context) (tfsdk.Schema, diag.Diagnostics) { + return tfsdk.Schema{ + Description: ` +Provides information about an existing Alias resource. + +An Alias allows a ` + "`vercel_deployment` to be accessed through a different URL.", + Attributes: map[string]tfsdk.Attribute{ + "team_id": { + Optional: true, + Type: types.StringType, + PlanModifiers: tfsdk.AttributePlanModifiers{tfsdk.RequiresReplace()}, + Description: "The ID of the team the Alias and Deployment exist under.", + }, + "alias": { + Required: true, + Type: types.StringType, + Description: "The Alias or Alias ID to be retrieved.", + }, + "deployment_id": { + Computed: true, + Type: types.StringType, + Description: "The ID of the Deployment the Alias is associated with.", + }, + "id": { + Computed: true, + Type: types.StringType, + }, + }, + }, nil +} + +// NewDataSource instantiates a new DataSource of this DataSourceType. +func (r dataSourceAliasType) NewDataSource(ctx context.Context, p tfsdk.Provider) (tfsdk.DataSource, diag.Diagnostics) { + return dataSourceAlias{ + p: *(p.(*provider)), + }, nil +} + +type dataSourceAlias struct { + p provider +} + +// Read will read the alias 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 (r dataSourceAlias) Read(ctx context.Context, req tfsdk.ReadDataSourceRequest, resp *tfsdk.ReadDataSourceResponse) { + var config Alias + diags := req.Config.Get(ctx, &config) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.p.client.GetAlias(ctx, config.Alias.Value, config.TeamID.Value) + if err != nil { + resp.Diagnostics.AddError( + "Error reading alias", + fmt.Sprintf("Could not read alias %s %s, unexpected error: %s", + config.TeamID.Value, + config.Alias.Value, + err, + ), + ) + return + } + + result := convertResponseToAlias(out, config) + tflog.Trace(ctx, "read alias", map[string]interface{}{ + "team_id": result.TeamID.Value, + "alias": result.Alias.Value, + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} diff --git a/vercel/data_source_alias_test.go b/vercel/data_source_alias_test.go new file mode 100644 index 00000000..bee7c639 --- /dev/null +++ b/vercel/data_source_alias_test.go @@ -0,0 +1,76 @@ +package vercel_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAcc_AliasDataSource(t *testing.T) { + name := acctest.RandString(16) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccAliasDataSourceConfig(name, ""), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.vercel_alias.test", "alias", fmt.Sprintf("test-acc-%s.vercel.app", name)), + resource.TestCheckResourceAttrSet("data.vercel_alias.test", "id"), + resource.TestCheckResourceAttrSet("data.vercel_alias.test", "deployment_id"), + ), + }, + }, + }) +} + +func TestAcc_AliasDataSourceTeam(t *testing.T) { + name := acctest.RandString(16) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccAliasDataSourceConfig(name, fmt.Sprintf("team_id = \"%s\"", testTeam())), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.vercel_alias.test", "alias", fmt.Sprintf("test-acc-%s.vercel.app", name)), + resource.TestCheckResourceAttr("data.vercel_alias.test", "team_id", testTeam()), + resource.TestCheckResourceAttrSet("data.vercel_alias.test", "id"), + resource.TestCheckResourceAttrSet("data.vercel_alias.test", "deployment_id"), + ), + }, + }, + }) +} + +func testAccAliasDataSourceConfig(name, team string) string { + return fmt.Sprintf(` +resource "vercel_project" "test" { + name = "test-acc-%[1]s" + %[2]s + git_repository = { + type = "github" + repo = "%[3]s" + } +} + +resource "vercel_deployment" "test" { + project_id = vercel_project.test.id + %[2]s + ref = "main" +} + +resource "vercel_alias" "test" { + alias = "test-acc-%[1]s.vercel.app" + %[2]s + deployment_id = vercel_deployment.test.id +} + +data "vercel_alias" "test" { + alias = vercel_alias.test.alias + %[2]s +} +`, name, team, testGithubRepo()) +} diff --git a/vercel/provider.go b/vercel/provider.go index d74176c0..eea47139 100644 --- a/vercel/provider.go +++ b/vercel/provider.go @@ -44,6 +44,7 @@ Use the navigation to the left to read about the available resources. // GetResources shows the available resources for the vercel provider func (p *provider) GetResources(_ context.Context) (map[string]tfsdk.ResourceType, diag.Diagnostics) { return map[string]tfsdk.ResourceType{ + "vercel_alias": resourceAliasType{}, "vercel_deployment": resourceDeploymentType{}, "vercel_project": resourceProjectType{}, "vercel_project_domain": resourceProjectDomainType{}, @@ -57,6 +58,7 @@ func (p *provider) GetDataSources(_ context.Context) (map[string]tfsdk.DataSourc "vercel_file": dataSourceFileType{}, "vercel_project": dataSourceProjectType{}, "vercel_project_directory": dataSourceProjectDirectoryType{}, + "vercel_alias": dataSourceAliasType{}, }, nil } diff --git a/vercel/resource_alias.go b/vercel/resource_alias.go new file mode 100644 index 00000000..74ed357e --- /dev/null +++ b/vercel/resource_alias.go @@ -0,0 +1,195 @@ +package vercel + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/vercel/terraform-provider-vercel/client" +) + +type resourceAliasType struct{} + +// GetSchema returns the schema information for an alias resource. +func (r resourceAliasType) GetSchema(_ context.Context) (tfsdk.Schema, diag.Diagnostics) { + return tfsdk.Schema{ + Description: ` +Provides an Alias resource. + +An Alias allows a ` + "`vercel_deployment` to be accessed through a different URL.", + Attributes: map[string]tfsdk.Attribute{ + "alias": { + Description: "The Alias we want to assign to the deployment defined in the URL.", + Required: true, + PlanModifiers: tfsdk.AttributePlanModifiers{tfsdk.RequiresReplace()}, + Type: types.StringType, + }, + "deployment_id": { + Description: "The id of the Deployment the Alias should be associated with.", + Required: true, + PlanModifiers: tfsdk.AttributePlanModifiers{tfsdk.RequiresReplace()}, + Type: types.StringType, + }, + "team_id": { + Optional: true, + Description: "The ID of the team the Alias and Deployment exist under.", + PlanModifiers: tfsdk.AttributePlanModifiers{tfsdk.RequiresReplace()}, + Type: types.StringType, + }, + "id": { + Computed: true, + Type: types.StringType, + }, + }, + }, nil +} + +// NewResource instantiates a new Resource of this ResourceType. +func (r resourceAliasType) NewResource(_ context.Context, p tfsdk.Provider) (tfsdk.Resource, diag.Diagnostics) { + return resourceAlias{ + p: *(p.(*provider)), + }, nil +} + +type resourceAlias struct { + p provider +} + +// Create will create an alias within Vercel. +// This is called automatically by the provider when a new resource should be created. +func (r resourceAlias) Create(ctx context.Context, req tfsdk.CreateResourceRequest, resp *tfsdk.CreateResourceResponse) { + if !r.p.configured { + resp.Diagnostics.AddError( + "Provider not configured", + "The provider hasn't been configured before apply. This leads to weird stuff happening, so we'd prefer if you didn't do that. Thanks!", + ) + return + } + + var plan Alias + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.p.client.CreateAlias(ctx, client.CreateAliasRequest{ + Alias: plan.Alias.Value, + }, plan.DeploymentID.Value, plan.TeamID.Value) + if err != nil { + resp.Diagnostics.AddError( + "Error creating alias", + "Could not create alias, unexpected error: "+err.Error(), + ) + return + } + + result := convertResponseToAlias(out, plan) + tflog.Trace(ctx, "created alias", map[string]interface{}{ + "team_id": plan.TeamID.Value, + "deployment_id": plan.DeploymentID.Value, + "alias_id": result.ID.Value, + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Read will read alias information by requesting it from the Vercel API, and will update terraform +// with this information. +func (r resourceAlias) Read(ctx context.Context, req tfsdk.ReadResourceRequest, resp *tfsdk.ReadResourceResponse) { + var state Alias + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.p.client.GetAlias(ctx, state.ID.Value, state.TeamID.Value) + if client.NotFound(err) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error reading alias", + fmt.Sprintf("Could not get alias %s %s, unexpected error: %s", + state.TeamID.Value, + state.ID.Value, + err, + ), + ) + return + } + + result := convertResponseToAlias(out, state) + tflog.Trace(ctx, "read alias", map[string]interface{}{ + "team_id": result.TeamID.Value, + "alias_id": result.ID.Value, + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Update updates the Alias state. +func (r resourceAlias) Update(ctx context.Context, req tfsdk.UpdateResourceRequest, resp *tfsdk.UpdateResourceResponse) { + var plan Alias + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + resp.Diagnostics.AddError( + "Error getting alias plan", + "Error getting alias plan", + ) + return + } + + var state Alias + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + +} + +// Delete deletes an Alias. +func (r resourceAlias) Delete(ctx context.Context, req tfsdk.DeleteResourceRequest, resp *tfsdk.DeleteResourceResponse) { + var state Alias + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + _, err := r.p.client.DeleteAlias(ctx, state.ID.Value, state.TeamID.Value) + if client.NotFound(err) { + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error deleting alias", + fmt.Sprintf( + "Could not delete alias %s, unexpected error: %s", + state.Alias.Value, + err, + ), + ) + return + } + + tflog.Trace(ctx, "deleted alias", map[string]interface{}{ + "team_id": state.TeamID.Value, + "alias_id": state.ID.Value, + }) +} diff --git a/vercel/resource_alias_model.go b/vercel/resource_alias_model.go new file mode 100644 index 00000000..b69c78f1 --- /dev/null +++ b/vercel/resource_alias_model.go @@ -0,0 +1,26 @@ +package vercel + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/vercel/terraform-provider-vercel/client" +) + +// Alias represents the terraform state for an alias resource. +type Alias struct { + Alias types.String `tfsdk:"alias"` + ID types.String `tfsdk:"id"` + DeploymentID types.String `tfsdk:"deployment_id"` + TeamID types.String `tfsdk:"team_id"` +} + +// convertResponseToAlias 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 convertResponseToAlias(response client.AliasResponse, plan Alias) Alias { + return Alias{ + Alias: plan.Alias, + ID: types.String{Value: response.UID}, + DeploymentID: types.String{Value: response.DeploymentID}, + TeamID: plan.TeamID, + } +} diff --git a/vercel/resource_alias_test.go b/vercel/resource_alias_test.go new file mode 100644 index 00000000..7b8e2750 --- /dev/null +++ b/vercel/resource_alias_test.go @@ -0,0 +1,108 @@ +package vercel_test + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/vercel/terraform-provider-vercel/client" +) + +func testCheckAliasExists(teamID, alias string) resource.TestCheckFunc { + return func(*terraform.State) error { + _, err := testClient().GetAlias(context.TODO(), alias, teamID) + return err + } +} + +func testCheckAliasDestroyed(n, teamID string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("no alias is set") + } + + _, err := testClient().GetAlias(context.TODO(), rs.Primary.ID, teamID) + if err == nil { + return fmt.Errorf("expected not_found error, but got no error") + } + if !client.NotFound(err) { + return fmt.Errorf("Unexpected error checking for deleted project: %s", err) + } + + return nil + } +} + +func TestAcc_AliasResource(t *testing.T) { + name := acctest.RandString(16) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: testCheckAliasDestroyed("vercel_alias.test", ""), + Steps: []resource.TestStep{ + { + Config: testAccAliasResourceConfig(name, ""), + Check: resource.ComposeAggregateTestCheckFunc( + testCheckAliasExists("", fmt.Sprintf("test-acc-%s.vercel.app", name)), + resource.TestCheckResourceAttr("vercel_alias.test", "alias", fmt.Sprintf("test-acc-%s.vercel.app", name)), + resource.TestCheckResourceAttrSet("vercel_alias.test", "id"), + resource.TestCheckResourceAttrSet("vercel_alias.test", "deployment_id"), + ), + }, + }, + }) +} + +func TestAcc_AliasResourceTeam(t *testing.T) { + name := acctest.RandString(16) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: testCheckAliasDestroyed("vercel_alias.test", testTeam()), + Steps: []resource.TestStep{ + { + Config: testAccAliasResourceConfig(name, fmt.Sprintf("team_id = \"%s\"", testTeam())), + Check: resource.ComposeAggregateTestCheckFunc( + testCheckAliasExists(testTeam(), fmt.Sprintf("test-acc-%s.vercel.app", name)), + resource.TestCheckResourceAttr("vercel_alias.test", "alias", fmt.Sprintf("test-acc-%s.vercel.app", name)), + resource.TestCheckResourceAttr("vercel_alias.test", "team_id", testTeam()), + resource.TestCheckResourceAttrSet("vercel_alias.test", "id"), + resource.TestCheckResourceAttrSet("vercel_alias.test", "deployment_id"), + ), + }, + }, + }) +} + +func testAccAliasResourceConfig(name, team string) string { + return fmt.Sprintf(` +resource "vercel_project" "test" { + name = "test-acc-%[1]s" + %[2]s + git_repository = { + type = "github" + repo = "%[3]s" + } +} + +resource "vercel_deployment" "test" { + project_id = vercel_project.test.id + ref = "main" + %[2]s +} + +resource "vercel_alias" "test" { + alias = "test-acc-%[1]s.vercel.app" + deployment_id = vercel_deployment.test.id + %[2]s +} +`, name, team, testGithubRepo()) +} diff --git a/vercel/resource_deployment.go b/vercel/resource_deployment.go index 3c8badff..39dcc62c 100644 --- a/vercel/resource_deployment.go +++ b/vercel/resource_deployment.go @@ -329,8 +329,7 @@ func (r resourceDeployment) Read(ctx context.Context, req tfsdk.ReadResourceRequ } out, err := r.p.client.GetDeployment(ctx, state.ID.Value, state.TeamID.Value) - var apiErr client.APIError - if err != nil && errors.As(err, &apiErr) && apiErr.StatusCode == 404 { + if client.NotFound(err) { resp.State.RemoveResource(ctx) return } diff --git a/vercel/resource_deployment_test.go b/vercel/resource_deployment_test.go index 1e481dc1..62b0bf47 100644 --- a/vercel/resource_deployment_test.go +++ b/vercel/resource_deployment_test.go @@ -2,7 +2,6 @@ package vercel_test import ( "context" - "errors" "fmt" "strings" "testing" @@ -179,18 +178,11 @@ func TestAcc_DeploymentWithDeleteOnDestroy(t *testing.T) { if err == nil { return fmt.Errorf("expected not_found error, but got no error") } - - var apiErr client.APIError - if err == nil { - return fmt.Errorf("Found deployment but expected it to have been deleted") - } - if err != nil && errors.As(err, &apiErr) { - if apiErr.StatusCode == 404 { - return nil - } - return fmt.Errorf("Unexpected error checking for deleted deployment: %s", apiErr) + if !client.NotFound(err) { + return fmt.Errorf("Unexpected error checking for deleted deployment: %s", err) } - return err + + return nil } } resource.Test(t, resource.TestCase{ diff --git a/vercel/resource_dns_record_test.go b/vercel/resource_dns_record_test.go index de1b65dd..b12ed60c 100644 --- a/vercel/resource_dns_record_test.go +++ b/vercel/resource_dns_record_test.go @@ -312,35 +312,35 @@ resource "vercel_dns_record" "a" { } resource "vercel_dns_record" "aaaa" { domain = "%[1]s" - name = "test-acc-%s-aaaa-record-updated" + name = "test-acc-%[2]s-aaaa-record-updated" type = "AAAA" ttl = 60 value = "::0" } resource "vercel_dns_record" "alias" { domain = "%[1]s" - name = "test-acc-%s-alias-updated" + name = "test-acc-%[2]s-alias-updated" type = "ALIAS" ttl = 60 value = "example2.com." } resource "vercel_dns_record" "caa" { domain = "%[1]s" - name = "test-acc-%s-caa-updated" + name = "test-acc-%[2]s-caa-updated" type = "CAA" ttl = 60 value = "1 issue \"letsencrypt.org\"" } resource "vercel_dns_record" "cname" { domain = "%[1]s" - name = "test-acc-%s-cname-updated" + name = "test-acc-%[2]s-cname-updated" type = "CNAME" ttl = 60 value = "example2.com." } resource "vercel_dns_record" "mx" { domain = "%[1]s" - name = "test-acc-%s-mx-updated" + name = "test-acc-%[2]s-mx-updated" type = "MX" ttl = 60 mx_priority = 333 diff --git a/vercel/resource_project.go b/vercel/resource_project.go index ce39994f..6c6153a3 100644 --- a/vercel/resource_project.go +++ b/vercel/resource_project.go @@ -154,8 +154,9 @@ For more detailed information, please see the [Vercel documentation](https://ver }), }, "id": { - Computed: true, - Type: types.StringType, + Computed: true, + Type: types.StringType, + PlanModifiers: tfsdk.AttributePlanModifiers{tfsdk.RequiresReplace()}, }, "install_command": { Optional: true, @@ -165,7 +166,7 @@ For more detailed information, please see the [Vercel documentation](https://ver "output_directory": { Optional: true, Type: types.StringType, - Description: "The output directory of the project. When null is used this value will be automatically detected.", + Description: "The output directory of the project. If omitted, this value will be automatically detected.", }, "public_source": { Optional: true, @@ -175,7 +176,7 @@ For more detailed information, please see the [Vercel documentation](https://ver "root_directory": { Optional: true, Type: types.StringType, - Description: "The name of a directory or relative path to the source code of your project. When null is used it will default to the project root.", + Description: "The name of a directory or relative path to the source code of your project. If omitted, it will default to the project root.", }, }, }, nil @@ -243,8 +244,7 @@ func (r resourceProject) Read(ctx context.Context, req tfsdk.ReadResourceRequest } out, err := r.p.client.GetProject(ctx, state.ID.Value, state.TeamID.Value) - var apiErr client.APIError - if err != nil && errors.As(err, &apiErr) && apiErr.StatusCode == 404 { + if client.NotFound(err) { resp.State.RemoveResource(ctx) return } diff --git a/vercel/resource_project_domain.go b/vercel/resource_project_domain.go index 525c1efc..e4d20833 100644 --- a/vercel/resource_project_domain.go +++ b/vercel/resource_project_domain.go @@ -147,8 +147,7 @@ func (r resourceProjectDomain) Read(ctx context.Context, req tfsdk.ReadResourceR } out, err := r.p.client.GetProjectDomain(ctx, state.ProjectID.Value, state.Domain.Value, state.TeamID.Value) - var apiErr client.APIError - if err != nil && errors.As(err, &apiErr) && apiErr.StatusCode == 404 { + if client.NotFound(err) { resp.State.RemoveResource(ctx) return } diff --git a/vercel/resource_project_domain_test.go b/vercel/resource_project_domain_test.go index 88339fe5..38b69b41 100644 --- a/vercel/resource_project_domain_test.go +++ b/vercel/resource_project_domain_test.go @@ -2,7 +2,6 @@ package vercel_test import ( "context" - "errors" "fmt" "testing" @@ -50,18 +49,13 @@ func testAccProjectDomainDestroy(n, teamID, domain string) resource.TestCheckFun } _, err := testClient().GetProjectDomain(context.TODO(), rs.Primary.ID, domain, teamID) - var apiErr client.APIError if err == nil { - return fmt.Errorf("Found project domain but expected it to have been deleted") + return fmt.Errorf("expected not_found error, but got no error") } - if err != nil && errors.As(err, &apiErr) { - if apiErr.StatusCode == 404 { - return nil - } - return fmt.Errorf("Unexpected error checking for deleted project domain: %s", apiErr) + if !client.NotFound(err) { + return fmt.Errorf("Unexpected error checking for deleted deployment: %s", err) } - - return err + return nil } } diff --git a/vercel/resource_project_test.go b/vercel/resource_project_test.go index b0e1bff7..c24aefc6 100644 --- a/vercel/resource_project_test.go +++ b/vercel/resource_project_test.go @@ -2,7 +2,6 @@ package vercel_test import ( "context" - "errors" "fmt" "testing" @@ -185,19 +184,14 @@ func testAccProjectDestroy(n, teamID string) resource.TestCheckFunc { } _, err := testClient().GetProject(context.TODO(), rs.Primary.ID, teamID) - - var apiErr client.APIError if err == nil { - return fmt.Errorf("Found project but expected it to have been deleted") + return fmt.Errorf("expected not_found error, but got no error") } - if err != nil && errors.As(err, &apiErr) { - if apiErr.StatusCode == 404 { - return nil - } - return fmt.Errorf("Unexpected error checking for deleted project: %s", apiErr) + if !client.NotFound(err) { + return fmt.Errorf("Unexpected error checking for deleted project: %s", err) } - return err + return nil } }