diff --git a/client/project.go b/client/project.go index 436cd5eb..14779bd5 100644 --- a/client/project.go +++ b/client/project.go @@ -31,6 +31,13 @@ type EnvironmentVariable struct { TeamID string `json:"-"` } +type DeploymentExpiration struct { + ExpirationPreview int `json:"expirationDays"` + ExpirationProduction int `json:"expirationDaysProduction"` + ExpirationCanceled int `json:"expirationDaysCanceled"` + ExpirationErrored int `json:"expirationDaysErrored"` +} + // CreateProjectRequest defines the information necessary to create a project. type CreateProjectRequest struct { BuildCommand *string `json:"buildCommand"` @@ -189,6 +196,7 @@ type ProjectResponse struct { SkewProtectionMaxAge int `json:"skewProtectionMaxAge"` GitComments *GitComments `json:"gitComments"` Security *Security `json:"security"` + DeploymentExpiration *DeploymentExpiration `json:"deploymentExpiration"` } type GitComments struct { diff --git a/client/project_deployment_retention.go b/client/project_deployment_retention.go new file mode 100644 index 00000000..5eaae325 --- /dev/null +++ b/client/project_deployment_retention.go @@ -0,0 +1,132 @@ +package client + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// CreateDeploymentRetentionRequest defines the information that needs to be passed to Vercel in order to +// create an deployment retention. +type DeploymentRetentionRequest struct { + ExpirationPreview string `json:"expiration,omitempty"` + ExpirationProduction string `json:"expirationProduction,omitempty"` + ExpirationCanceled string `json:"expirationCanceled,omitempty"` + ExpirationErrored string `json:"expirationErrored,omitempty"` +} + +// UpdateDeploymentRetentionRequest defines the information that needs to be passed to Vercel in order to +// update an deployment retention. +type UpdateDeploymentRetentionRequest struct { + DeploymentRetention DeploymentRetentionRequest + ProjectID string + TeamID string +} + +// DeleteDeploymentRetention will remove any existing deployment retention for a given project. +func (c *Client) DeleteDeploymentRetention(ctx context.Context, projectID, teamID string) error { + url := fmt.Sprintf("%s/v9/projects/%s/deployment-expiration", c.baseURL, projectID) + if c.teamID(teamID) != "" { + url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(teamID)) + } + unlimited := "unlimited" + payload := string(mustMarshal(DeploymentRetentionRequest{ExpirationPreview: unlimited, ExpirationProduction: unlimited, ExpirationCanceled: unlimited, ExpirationErrored: unlimited})) + + tflog.Info(ctx, "updating deployment expiration", map[string]interface{}{ + "url": url, + "payload": payload, + }) + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "PATCH", + url: url, + body: payload, + }, nil) + return err +} + +type deploymentExpirationResponse struct { + DeploymentExpiration struct { + Expiration string `json:"expiration"` + ExpirationProduction string `json:"expirationProduction"` + ExpirationCanceled string `json:"expirationCanceled"` + ExpirationErrored string `json:"expirationErrored"` + } `json:"deploymentExpiration"` +} + +var DeploymentRetentionDaysToString = map[int]string{ + 1: "1d", + 7: "1w", + 30: "1m", + 60: "2m", + 90: "3m", + 180: "6m", + 365: "1y", + 36500: "unlimited", +} + +var DeploymentRetentionStringToDays = map[string]int{ + "1d": 1, + "1w": 7, + "1m": 30, + "2m": 60, + "3m": 90, + "6m": 180, + "1y": 365, + "unlimited": 36500, +} + +func (d deploymentExpirationResponse) toDeploymentExpiration() DeploymentExpiration { + return DeploymentExpiration{ + ExpirationPreview: DeploymentRetentionStringToDays[d.DeploymentExpiration.Expiration], + ExpirationProduction: DeploymentRetentionStringToDays[d.DeploymentExpiration.ExpirationProduction], + ExpirationCanceled: DeploymentRetentionStringToDays[d.DeploymentExpiration.ExpirationCanceled], + ExpirationErrored: DeploymentRetentionStringToDays[d.DeploymentExpiration.ExpirationErrored], + } +} + +// UpdateDeploymentRetention will update an existing deployment retention to the latest information. +func (c *Client) UpdateDeploymentRetention(ctx context.Context, request UpdateDeploymentRetentionRequest) (DeploymentExpiration, error) { + url := fmt.Sprintf("%s/v9/projects/%s/deployment-expiration", c.baseURL, request.ProjectID) + if c.teamID(request.TeamID) != "" { + url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(request.TeamID)) + } + payload := string(mustMarshal(request.DeploymentRetention)) + + tflog.Info(ctx, "updating deployment expiration", map[string]interface{}{ + "url": url, + "payload": payload, + }) + var d deploymentExpirationResponse + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "PATCH", + url: url, + body: payload, + }, &d) + return d.toDeploymentExpiration(), err +} + +// GetDeploymentRetention returns the deployment retention for a given project. +func (c *Client) GetDeploymentRetention(ctx context.Context, projectID, teamID string) (d DeploymentExpiration, err error) { + url := fmt.Sprintf("%s/v2/projects/%s", c.baseURL, projectID) + if c.teamID(teamID) != "" { + url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(teamID)) + } + + tflog.Info(ctx, "getting deployment retention", map[string]interface{}{ + "url": url, + }) + var p ProjectResponse + err = c.doRequest(clientRequest{ + ctx: ctx, + method: "GET", + url: url, + body: "", + }, &p) + if p.DeploymentExpiration == nil { + return DeploymentExpiration{}, fmt.Errorf("deployment retention not found") + } + return *p.DeploymentExpiration, err +} diff --git a/docs/data-sources/project_deployment_retention.md b/docs/data-sources/project_deployment_retention.md new file mode 100644 index 00000000..438b3ba6 --- /dev/null +++ b/docs/data-sources/project_deployment_retention.md @@ -0,0 +1,38 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_project_deployment_retention Data Source - terraform-provider-vercel" +subcategory: "" +description: |- + Provides a Project Deployment Retention datasource. + A Project Deployment Retention datasource details information about Deployment Retention on a Vercel Project. + For more detailed information, please see the Vercel documentation https://vercel.com/docs/security/deployment-retention. +--- + +# vercel_project_deployment_retention (Data Source) + +Provides a Project Deployment Retention datasource. + +A Project Deployment Retention datasource details information about Deployment Retention on a Vercel Project. + +For more detailed information, please see the [Vercel documentation](https://vercel.com/docs/security/deployment-retention). + + + + +## Schema + +### Required + +- `project_id` (String) The ID of the Project for the retention policy + +### Optional + +- `team_id` (String) The ID of the Vercel team. + +### Read-Only + +- `expiration_canceled` (String) The retention period for canceled deployments. +- `expiration_errored` (String) The retention period for errored deployments. +- `expiration_preview` (String) The retention period for preview deployments. +- `expiration_production` (String) The retention period for production deployments. +- `id` (String) The ID of this resource. diff --git a/docs/resources/project_deployment_retention.md b/docs/resources/project_deployment_retention.md new file mode 100644 index 00000000..feadd710 --- /dev/null +++ b/docs/resources/project_deployment_retention.md @@ -0,0 +1,78 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_project_deployment_retention Resource - terraform-provider-vercel" +subcategory: "" +description: |- + Provides a Project Deployment Retention resource. + A Project Deployment Retention resource defines an Deployment Retention on a Vercel Project. + For more detailed information, please see the Vercel documentation https://vercel.com/docs/security/deployment-retention. +--- + +# vercel_project_deployment_retention (Resource) + +Provides a Project Deployment Retention resource. + +A Project Deployment Retention resource defines an Deployment Retention on a Vercel Project. + +For more detailed information, please see the [Vercel documentation](https://vercel.com/docs/security/deployment-retention). + +## Example Usage + +```terraform +resource "vercel_project" "example" { + name = "example-project" + + git_repository = { + type = "github" + repo = "vercel/some-repo" + } +} + +# An unlimited deployment retention policy that will be created +# for this project for all deployments. +resource "vercel_project_deployment_retention" "example_unlimited" { + project_id = vercel_project.example.id + team_id = vercel_project.example.team_id + expiration_preview = "unlimited" + expiration_production = "unlimited" + expiration_canceled = "unlimited" + expiration_errored = "unlimited" +} + +# A customized deployment retention policy that will be created +# for this project for all deployments. +resource "vercel_project_deployment_retention" "example_customized" { + project_id = vercel_project.example.id + team_id = vercel_project.example.team_id + expiration_preview = "3m" + expiration_production = "1y" + expiration_canceled = "1m" + expiration_errored = "2m" +} +``` + + +## Schema + +### Required + +- `project_id` (String) The ID of the Project for the retention policy + +### Optional + +- `expiration_canceled` (String) The retention period for canceled deployments. Should be one of '1m', '2m', '3m', '6m', '1y', 'unlimited'. +- `expiration_errored` (String) The retention period for errored deployments. Should be one of '1m', '2m', '3m', '6m', '1y', 'unlimited'. +- `expiration_preview` (String) The retention period for preview deployments. Should be one of '1m', '2m', '3m', '6m', '1y', 'unlimited'. +- `expiration_production` (String) The retention period for production deployments. Should be one of '1m', '2m', '3m', '6m', '1y', 'unlimited'. +- `team_id` (String) The ID of the Vercel team. + +## Import + +Import is supported using the following syntax: + +```shell +# You can import via the team_id and project_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. +terraform import vercel_project_deployment_retention.example team_xxxxxxxxxxxxxxxxxxxxxxxx/prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` diff --git a/examples/resources/vercel_project_deployment_retention/import.sh b/examples/resources/vercel_project_deployment_retention/import.sh new file mode 100644 index 00000000..7cd566b2 --- /dev/null +++ b/examples/resources/vercel_project_deployment_retention/import.sh @@ -0,0 +1,4 @@ +# You can import via the team_id and project_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. +terraform import vercel_project_deployment_retention.example team_xxxxxxxxxxxxxxxxxxxxxxxx/prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/examples/resources/vercel_project_deployment_retention/resource.tf b/examples/resources/vercel_project_deployment_retention/resource.tf new file mode 100644 index 00000000..cec8222b --- /dev/null +++ b/examples/resources/vercel_project_deployment_retention/resource.tf @@ -0,0 +1,30 @@ +resource "vercel_project" "example" { + name = "example-project" + + git_repository = { + type = "github" + repo = "vercel/some-repo" + } +} + +# An unlimited deployment retention policy that will be created +# for this project for all deployments. +resource "vercel_project_deployment_retention" "example_unlimited" { + project_id = vercel_project.example.id + team_id = vercel_project.example.team_id + expiration_preview = "unlimited" + expiration_production = "unlimited" + expiration_canceled = "unlimited" + expiration_errored = "unlimited" +} + +# A customized deployment retention policy that will be created +# for this project for all deployments. +resource "vercel_project_deployment_retention" "example_customized" { + project_id = vercel_project.example.id + team_id = vercel_project.example.team_id + expiration_preview = "3m" + expiration_production = "1y" + expiration_canceled = "1m" + expiration_errored = "2m" +} diff --git a/vercel/data_source_project_deployment_retention.go b/vercel/data_source_project_deployment_retention.go new file mode 100644 index 00000000..cdb91735 --- /dev/null +++ b/vercel/data_source_project_deployment_retention.go @@ -0,0 +1,148 @@ +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-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/vercel/terraform-provider-vercel/client" +) + +var ( + _ datasource.DataSource = &projectDeploymentRetentionDataSource{} + _ datasource.DataSourceWithConfigure = &projectDeploymentRetentionDataSource{} +) + +func newProjectDeploymentRetentionDataSource() datasource.DataSource { + return &projectDeploymentRetentionDataSource{} +} + +type projectDeploymentRetentionDataSource struct { + client *client.Client +} + +func (r *projectDeploymentRetentionDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_project_deployment_retention" +} + +func (r *projectDeploymentRetentionDataSource) 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 DataSource Configure Type", + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.client = client +} + +// Schema returns the schema information for a project deployment retention datasource. +func (r *projectDeploymentRetentionDataSource) Schema(_ context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: ` +Provides a Project Deployment Retention datasource. + +A Project Deployment Retention datasource details information about Deployment Retention on a Vercel Project. + +For more detailed information, please see the [Vercel documentation](https://vercel.com/docs/security/deployment-retention). +`, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + }, + "expiration_preview": schema.StringAttribute{ + Computed: true, + Description: "The retention period for preview deployments.", + }, + "expiration_production": schema.StringAttribute{ + Computed: true, + Description: "The retention period for production deployments.", + }, + "expiration_canceled": schema.StringAttribute{ + Computed: true, + Description: "The retention period for canceled deployments.", + }, + "expiration_errored": schema.StringAttribute{ + Computed: true, + Description: "The retention period for errored deployments.", + }, + "project_id": schema.StringAttribute{ + Description: "The ID of the Project for the retention policy", + Required: true, + }, + "team_id": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The ID of the Vercel team.", + }, + }, + } +} + +type ProjectDeploymentRetentionWithID struct { + ExpirationPreview types.String `tfsdk:"expiration_preview"` + ExpirationProduction types.String `tfsdk:"expiration_production"` + ExpirationCanceled types.String `tfsdk:"expiration_canceled"` + ExpirationErrored types.String `tfsdk:"expiration_errored"` + ProjectID types.String `tfsdk:"project_id"` + TeamID types.String `tfsdk:"team_id"` + ID types.String `tfsdk:"id"` +} + +// Read will read an deployment retention of a Vercel project by requesting it from the Vercel API, and will update terraform +// with this information. +func (r *projectDeploymentRetentionDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config ProjectDeploymentRetentionWithID + diags := req.Config.Get(ctx, &config) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.client.GetDeploymentRetention(ctx, config.ProjectID.ValueString(), config.TeamID.ValueString()) + if client.NotFound(err) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error reading project deployment retention", + fmt.Sprintf("Could not get project deployment retention %s %s, unexpected error: %s", + config.ProjectID.ValueString(), + config.TeamID.ValueString(), + err, + ), + ) + return + } + + result := convertResponseToProjectDeploymentRetention(out, config.ProjectID, config.TeamID) + tflog.Info(ctx, "read project deployment retention", map[string]interface{}{ + "team_id": result.TeamID.ValueString(), + "project_id": result.ProjectID.ValueString(), + }) + + diags = resp.State.Set(ctx, ProjectDeploymentRetentionWithID{ + ExpirationPreview: result.ExpirationPreview, + ExpirationProduction: result.ExpirationProduction, + ExpirationCanceled: result.ExpirationCanceled, + ExpirationErrored: result.ExpirationErrored, + ProjectID: result.ProjectID, + TeamID: result.TeamID, + ID: result.ProjectID, + }) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} diff --git a/vercel/data_source_project_deployment_retention_test.go b/vercel/data_source_project_deployment_retention_test.go new file mode 100644 index 00000000..1773b21d --- /dev/null +++ b/vercel/data_source_project_deployment_retention_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_ProjectDeploymentRetentionDataSource(t *testing.T) { + nameSuffix := acctest.RandString(16) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: resource.ComposeAggregateTestCheckFunc( + testAccProjectDestroy("vercel_project.example", testTeam()), + ), + Steps: []resource.TestStep{ + { + Config: testAccProjectDeploymentRetentionDataSourceConfig(nameSuffix), + Check: resource.ComposeAggregateTestCheckFunc( + testAccProjectDeploymentRetentionExists("vercel_project_deployment_retention.example", testTeam()), + resource.TestCheckResourceAttr("vercel_project_deployment_retention.example", "expiration_preview", "1m"), + resource.TestCheckResourceAttr("vercel_project_deployment_retention.example", "expiration_production", "unlimited"), + resource.TestCheckResourceAttr("vercel_project_deployment_retention.example", "expiration_canceled", "unlimited"), + resource.TestCheckResourceAttr("vercel_project_deployment_retention.example", "expiration_errored", "unlimited"), + ), + }, + }, + }) +} + +func testAccProjectDeploymentRetentionDataSourceConfig(projectName string) string { + return fmt.Sprintf(` +resource "vercel_project" "example" { + name = "test-acc-example-project-%[1]s" + %[3]s + + git_repository = { + type = "github" + repo = "%[2]s" + } +} + +resource "vercel_project_deployment_retention" "example" { + project_id = vercel_project.example.id + %[3]s + expiration_preview = "1m" +} + +data "vercel_project_deployment_retention" "example" { + project_id = vercel_project_deployment_retention.example.project_id + %[3]s +} +`, projectName, testGithubRepo(), teamIDConfig()) +} diff --git a/vercel/provider.go b/vercel/provider.go index 4d024486..bbcb7695 100644 --- a/vercel/provider.go +++ b/vercel/provider.go @@ -58,6 +58,7 @@ func (p *vercelProvider) Resources(_ context.Context) []func() resource.Resource newEdgeConfigSchemaResource, newEdgeConfigTokenResource, newLogDrainResource, + newProjectDeploymentRetentionResource, newProjectDomainResource, newProjectEnvironmentVariableResource, newProjectFunctionCPUResource, @@ -80,6 +81,7 @@ func (p *vercelProvider) DataSources(_ context.Context) []func() datasource.Data newLogDrainDataSource, newPrebuiltProjectDataSource, newProjectDataSource, + newProjectDeploymentRetentionDataSource, newProjectDirectoryDataSource, newProjectFunctionCPUDataSource, newSharedEnvironmentVariableDataSource, diff --git a/vercel/resource_project.go b/vercel/resource_project.go index 0c410dc1..609cab4a 100644 --- a/vercel/resource_project.go +++ b/vercel/resource_project.go @@ -1032,7 +1032,7 @@ func convertResponseToProject(ctx context.Context, response client.ProjectRespon } } - var oidcTokenConfig *OIDCTokenConfig = &OIDCTokenConfig{ + var oidcTokenConfig = &OIDCTokenConfig{ Enabled: types.BoolValue(false), } if response.OIDCTokenConfig != nil { diff --git a/vercel/resource_project_deployment_retention.go b/vercel/resource_project_deployment_retention.go new file mode 100644 index 00000000..8c9125aa --- /dev/null +++ b/vercel/resource_project_deployment_retention.go @@ -0,0 +1,355 @@ +package vercel + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "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-log/tflog" + "github.com/vercel/terraform-provider-vercel/client" +) + +var ( + _ resource.Resource = &projectDeploymentRetentionResource{} + _ resource.ResourceWithConfigure = &projectDeploymentRetentionResource{} + _ resource.ResourceWithImportState = &projectDeploymentRetentionResource{} +) + +func newProjectDeploymentRetentionResource() resource.Resource { + return &projectDeploymentRetentionResource{} +} + +type projectDeploymentRetentionResource struct { + client *client.Client +} + +func (r *projectDeploymentRetentionResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_project_deployment_retention" +} + +func (r *projectDeploymentRetentionResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.client = client +} + +// Schema returns the schema information for a project deployment retention resource. +func (r *projectDeploymentRetentionResource) Schema(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: ` +Provides a Project Deployment Retention resource. + +A Project Deployment Retention resource defines an Deployment Retention on a Vercel Project. + +For more detailed information, please see the [Vercel documentation](https://vercel.com/docs/security/deployment-retention). +`, + Attributes: map[string]schema.Attribute{ + "expiration_preview": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The retention period for preview deployments. Should be one of '1m', '2m', '3m', '6m', '1y', 'unlimited'.", + Default: stringdefault.StaticString("unlimited"), + Validators: []validator.String{ + stringOneOf("1m", "2m", "3m", "6m", "1y", "unlimited"), + }, + }, + "expiration_production": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The retention period for production deployments. Should be one of '1m', '2m', '3m', '6m', '1y', 'unlimited'.", + Default: stringdefault.StaticString("unlimited"), + Validators: []validator.String{ + stringOneOf("1m", "2m", "3m", "6m", "1y", "unlimited"), + }, + }, + "expiration_canceled": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The retention period for canceled deployments. Should be one of '1m', '2m', '3m', '6m', '1y', 'unlimited'.", + Default: stringdefault.StaticString("unlimited"), + Validators: []validator.String{ + stringOneOf("1m", "2m", "3m", "6m", "1y", "unlimited"), + }, + }, + "expiration_errored": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The retention period for errored deployments. Should be one of '1m', '2m', '3m', '6m', '1y', 'unlimited'.", + Default: stringdefault.StaticString("unlimited"), + Validators: []validator.String{ + stringOneOf("1m", "2m", "3m", "6m", "1y", "unlimited"), + }, + }, + "project_id": schema.StringAttribute{ + Description: "The ID of the Project for the retention policy", + Required: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "team_id": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The ID of the Vercel team.", + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplaceIfConfigured(), stringplanmodifier.UseStateForUnknown()}, + }, + }, + } +} + +// ProjectDeploymentRetention reflects the state terraform stores internally for a project deployment retention. +type ProjectDeploymentRetention struct { + ExpirationPreview types.String `tfsdk:"expiration_preview"` + ExpirationProduction types.String `tfsdk:"expiration_production"` + ExpirationCanceled types.String `tfsdk:"expiration_canceled"` + ExpirationErrored types.String `tfsdk:"expiration_errored"` + ProjectID types.String `tfsdk:"project_id"` + TeamID types.String `tfsdk:"team_id"` +} + +func (e *ProjectDeploymentRetention) toUpdateDeploymentRetentionRequest() client.UpdateDeploymentRetentionRequest { + return client.UpdateDeploymentRetentionRequest{ + DeploymentRetention: client.DeploymentRetentionRequest{ + ExpirationPreview: e.ExpirationPreview.ValueString(), + ExpirationProduction: e.ExpirationProduction.ValueString(), + ExpirationCanceled: e.ExpirationCanceled.ValueString(), + ExpirationErrored: e.ExpirationErrored.ValueString(), + }, + ProjectID: e.ProjectID.ValueString(), + TeamID: e.TeamID.ValueString(), + } +} + +// convertResponseToProjectDeploymentRetention 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 convertResponseToProjectDeploymentRetention(response client.DeploymentExpiration, projectID types.String, teamID types.String) ProjectDeploymentRetention { + return ProjectDeploymentRetention{ + ExpirationPreview: types.StringValue(client.DeploymentRetentionDaysToString[response.ExpirationPreview]), + ExpirationProduction: types.StringValue(client.DeploymentRetentionDaysToString[response.ExpirationProduction]), + ExpirationCanceled: types.StringValue(client.DeploymentRetentionDaysToString[response.ExpirationCanceled]), + ExpirationErrored: types.StringValue(client.DeploymentRetentionDaysToString[response.ExpirationErrored]), + TeamID: teamID, + ProjectID: projectID, + } +} + +// Create will create a new project deployment retention for a Vercel project. +// This is called automatically by the provider when a new resource should be created. +func (r *projectDeploymentRetentionResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan ProjectDeploymentRetention + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + _, err := r.client.GetProject(ctx, plan.ProjectID.ValueString(), plan.TeamID.ValueString()) + if client.NotFound(err) { + resp.Diagnostics.AddError( + "Error creating project deployment retention", + "Could not find project, please make sure both the project_id and team_id match the project and team you wish to deploy to.", + ) + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error creating project deployment retention", + "Error reading project information, unexpected error: "+err.Error(), + ) + return + } + + response, err := r.client.UpdateDeploymentRetention(ctx, plan.toUpdateDeploymentRetentionRequest()) + if err != nil { + resp.Diagnostics.AddError( + "Error creating project deployment retention", + "Could not create project deployment retention, unexpected error: "+err.Error(), + ) + return + } + + result := convertResponseToProjectDeploymentRetention(response, plan.ProjectID, plan.TeamID) + + tflog.Info(ctx, "created project deployment retention", map[string]interface{}{ + "team_id": result.TeamID.ValueString(), + "project_id": result.ProjectID.ValueString(), + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Read will read an deployment retention of a Vercel project by requesting it from the Vercel API, and will update terraform +// with this information. +func (r *projectDeploymentRetentionResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state ProjectDeploymentRetention + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.client.GetDeploymentRetention(ctx, state.ProjectID.ValueString(), state.TeamID.ValueString()) + if client.NotFound(err) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error reading project deployment retention", + fmt.Sprintf("Could not get project deployment retention %s %s, unexpected error: %s", + state.ProjectID.ValueString(), + state.TeamID.ValueString(), + err, + ), + ) + return + } + + result := convertResponseToProjectDeploymentRetention(out, state.ProjectID, state.TeamID) + tflog.Info(ctx, "read project deployment retention", map[string]interface{}{ + "team_id": result.TeamID.ValueString(), + "project_id": result.ProjectID.ValueString(), + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Delete deletes a Vercel project deployment retention. +func (r *projectDeploymentRetentionResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state ProjectDeploymentRetention + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteDeploymentRetention(ctx, state.ProjectID.ValueString(), state.TeamID.ValueString()) + if client.NotFound(err) { + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error deleting project deployment retention", + fmt.Sprintf( + "Could not delete project deployment retention %s, unexpected error: %s", + state.ProjectID.ValueString(), + err, + ), + ) + return + } + + tflog.Info(ctx, "deleted project deployment retention", map[string]interface{}{ + "team_id": state.TeamID.ValueString(), + "project_id": state.ProjectID.ValueString(), + }) +} + +// Update updates the project deployment retention of a Vercel project state. +func (r *projectDeploymentRetentionResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan ProjectDeploymentRetention + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + response, err := r.client.UpdateDeploymentRetention(ctx, plan.toUpdateDeploymentRetentionRequest()) + if err != nil { + resp.Diagnostics.AddError( + "Error updating project deployment retention", + "Could not update project deployment retention, unexpected error: "+err.Error(), + ) + return + } + + result := convertResponseToProjectDeploymentRetention(response, plan.ProjectID, plan.TeamID) + + tflog.Info(ctx, "updated project deployment retention", map[string]interface{}{ + "team_id": result.TeamID.ValueString(), + "project_id": result.ProjectID.ValueString(), + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// splitID is a helper function for splitting an import ID into the corresponding parts. +// It also validates whether the ID is in a correct format. +func splitProjectDeploymentRetentionID(id string) (teamID, projectID string, ok bool) { + attributes := strings.Split(id, "/") + if len(attributes) == 2 { + return attributes[0], attributes[1], true + } + if len(attributes) == 1 { + return "", attributes[0], true + } + + return "", "", false +} + +// ImportState takes an identifier and reads all the project deployment retention information from the Vercel API. +// The results are then stored in terraform state. +func (r *projectDeploymentRetentionResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + teamID, projectID, ok := splitProjectDeploymentRetentionID(req.ID) + if !ok { + resp.Diagnostics.AddError( + "Error importing project deployment retention", + fmt.Sprintf("Invalid id '%s' specified. should be in format \"team_id/project_id\" or \"project_id\"", req.ID), + ) + } + + out, err := r.client.GetDeploymentRetention(ctx, projectID, teamID) + if err != nil { + resp.Diagnostics.AddError( + "Error reading project deployment retention", + fmt.Sprintf("Could not get project deployment retention %s %s, unexpected error: %s", + teamID, + projectID, + err, + ), + ) + return + } + + result := convertResponseToProjectDeploymentRetention(out, types.StringValue(projectID), types.StringValue(teamID)) + tflog.Info(ctx, "imported project deployment retention", map[string]interface{}{ + "team_id": result.TeamID.ValueString(), + "project_id": result.ProjectID.ValueString(), + }) + + diags := resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} diff --git a/vercel/resource_project_deployment_retention_test.go b/vercel/resource_project_deployment_retention_test.go new file mode 100644 index 00000000..63cbc4dd --- /dev/null +++ b/vercel/resource_project_deployment_retention_test.go @@ -0,0 +1,135 @@ +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" +) + +func testAccProjectDeploymentRetentionExists(n, teamID string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("no ID is set") + } + + _, err := testClient().GetDeploymentRetention(context.TODO(), rs.Primary.Attributes["project_id"], teamID) + return err + } +} + +func TestAcc_ProjectDeploymentRetention(t *testing.T) { + nameSuffix := acctest.RandString(16) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: resource.ComposeAggregateTestCheckFunc( + testAccProjectDestroy("vercel_project.example", testTeam()), + ), + Steps: []resource.TestStep{ + { + Config: testAccProjectDeploymentRetentionsConfigWithMissingFields(nameSuffix), + Check: resource.ComposeAggregateTestCheckFunc( + testAccProjectDeploymentRetentionExists("vercel_project_deployment_retention.example", testTeam()), + resource.TestCheckResourceAttr("vercel_project_deployment_retention.example", "expiration_preview", "unlimited"), + resource.TestCheckResourceAttr("vercel_project_deployment_retention.example", "expiration_production", "unlimited"), + resource.TestCheckResourceAttr("vercel_project_deployment_retention.example", "expiration_canceled", "unlimited"), + resource.TestCheckResourceAttr("vercel_project_deployment_retention.example", "expiration_errored", "unlimited"), + ), + }, + { + Config: testAccProjectDeploymentRetentionsConfig(nameSuffix), + Check: resource.ComposeAggregateTestCheckFunc( + testAccProjectDeploymentRetentionExists("vercel_project_deployment_retention.example", testTeam()), + resource.TestCheckResourceAttr("vercel_project_deployment_retention.example", "expiration_preview", "1m"), + resource.TestCheckResourceAttr("vercel_project_deployment_retention.example", "expiration_production", "2m"), + resource.TestCheckResourceAttr("vercel_project_deployment_retention.example", "expiration_canceled", "3m"), + resource.TestCheckResourceAttr("vercel_project_deployment_retention.example", "expiration_errored", "1y"), + ), + }, + { + Config: testAccProjectDeploymentRetentionsConfigUpdated(nameSuffix), + Check: resource.ComposeAggregateTestCheckFunc( + testAccProjectDeploymentRetentionExists("vercel_project_deployment_retention.example", testTeam()), + resource.TestCheckResourceAttr("vercel_project_deployment_retention.example", "expiration_preview", "2m"), + resource.TestCheckResourceAttr("vercel_project_deployment_retention.example", "expiration_production", "3m"), + resource.TestCheckResourceAttr("vercel_project_deployment_retention.example", "expiration_canceled", "6m"), + resource.TestCheckResourceAttr("vercel_project_deployment_retention.example", "expiration_errored", "1m"), + ), + }, + }, + }) +} + +func testAccProjectDeploymentRetentionsConfig(projectName string) string { + return fmt.Sprintf(` +resource "vercel_project" "example" { + name = "test-acc-example-project-%[1]s" + %[3]s + + git_repository = { + type = "github" + repo = "%[2]s" + } +} + +resource "vercel_project_deployment_retention" "example" { + project_id = vercel_project.example.id + %[3]s + expiration_preview = "1m" + expiration_production = "2m" + expiration_canceled = "3m" + expiration_errored = "1y" +} +`, projectName, testGithubRepo(), teamIDConfig()) +} + +func testAccProjectDeploymentRetentionsConfigUpdated(projectName string) string { + return fmt.Sprintf(` +resource "vercel_project" "example" { + name = "test-acc-example-project-%[1]s" + %[3]s + + git_repository = { + type = "github" + repo = "%[2]s" + } +} + +resource "vercel_project_deployment_retention" "example" { + project_id = vercel_project.example.id + %[3]s + expiration_preview = "2m" + expiration_production = "3m" + expiration_canceled = "6m" + expiration_errored = "1m" +} +`, projectName, testGithubRepo(), teamIDConfig()) +} + +func testAccProjectDeploymentRetentionsConfigWithMissingFields(projectName string) string { + return fmt.Sprintf(` +resource "vercel_project" "example" { + name = "test-acc-example-project-%[1]s" + %[3]s + + git_repository = { + type = "github" + repo = "%[2]s" + } +} + +resource "vercel_project_deployment_retention" "example" { + project_id = vercel_project.example.id + %[3]s +} +`, projectName, testGithubRepo(), teamIDConfig()) +}