diff --git a/client/project_rolling_release.go b/client/project_rolling_release.go new file mode 100644 index 00000000..6bf41879 --- /dev/null +++ b/client/project_rolling_release.go @@ -0,0 +1,137 @@ +package client + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// RollingReleaseStage represents a stage in a rolling release +type RollingReleaseStage struct { + TargetPercentage int `json:"targetPercentage"` // Required: 0-100 + Duration *int `json:"duration,omitempty"` // Required for automatic advancement: 1-10000 minutes + RequireApproval bool `json:"requireApproval,omitempty"` // Only in response for manual-approval type +} + +// RollingRelease represents the rolling release configuration +type RollingRelease struct { + Enabled bool `json:"enabled"` // Required + AdvancementType string `json:"advancementType"` // Required when enabled=true: 'automatic' or 'manual-approval' + Stages []RollingReleaseStage `json:"stages"` // Required when enabled=true: 2-10 stages +} + +type RollingReleaseInfo struct { + RollingRelease RollingRelease `json:"rollingRelease"` + ProjectID string `json:"projectId"` + TeamID string `json:"teamId"` +} + +// GetRollingRelease returns the rolling release for a given project. +func (c *Client) GetRollingRelease(ctx context.Context, projectID, teamID string) (RollingReleaseInfo, error) { + teamId := c.TeamID(teamID) + url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, projectID, teamId) + + var d RollingReleaseInfo + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "GET", + url: url, + }, &d) + + if err != nil { + return RollingReleaseInfo{}, fmt.Errorf("error getting rolling-release: %w", err) + } + + d.ProjectID = projectID + d.TeamID = teamId + + return d, nil +} + +// CreateRollingReleaseRequest defines the information that needs to be passed to Vercel in order to +// create a new rolling release. +type CreateRollingReleaseRequest struct { + RollingRelease RollingRelease `json:"rollingRelease"` + ProjectID string `json:"projectId"` + TeamID string `json:"teamId"` +} + +// CreateRollingRelease will create a new rolling release for a given project. +func (c *Client) CreateRollingRelease(ctx context.Context, request CreateRollingReleaseRequest) (RollingReleaseInfo, error) { + request.TeamID = c.TeamID(request.TeamID) + enableRequest := map[string]any{ + "enabled": true, + "advancementType": request.RollingRelease.AdvancementType, + "stages": request.RollingRelease.Stages, + } + + var result RollingReleaseInfo + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "PATCH", + url: fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, request.ProjectID, request.TeamID), + body: string(mustMarshal(enableRequest)), + }, &result) + if err != nil { + return RollingReleaseInfo{}, fmt.Errorf("error enabling rolling release: %w", err) + } + + result.ProjectID = request.ProjectID + result.TeamID = request.TeamID + tflog.Info(ctx, "created rolling release", map[string]any{ + "response": result, + "request": request, + }) + return result, nil +} + +// UpdateRollingReleaseRequest defines the information that needs to be passed to Vercel in order to +// update a rolling release. +type UpdateRollingReleaseRequest struct { + RollingRelease RollingRelease `json:"rollingRelease"` + ProjectID string `json:"projectId"` + TeamID string `json:"teamId"` +} + +// UpdateRollingRelease will update an existing rolling release to the latest information. +func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRollingReleaseRequest) (RollingReleaseInfo, error) { + request.TeamID = c.TeamID(request.TeamID) + enableRequest := map[string]any{ + "enabled": true, + "advancementType": request.RollingRelease.AdvancementType, + "stages": request.RollingRelease.Stages, + } + + var result RollingReleaseInfo + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "PATCH", + url: fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, request.ProjectID, request.TeamID), + body: string(mustMarshal(enableRequest)), + }, &result) + if err != nil { + return RollingReleaseInfo{}, fmt.Errorf("error enabling rolling release: %w", err) + } + + result.ProjectID = request.ProjectID + result.TeamID = request.TeamID + tflog.Info(ctx, "updated rolling release", map[string]any{ + "response": result, + "request": request, + }) + return result, nil +} + +// DeleteRollingRelease will delete the rolling release for a given project. +func (c *Client) DeleteRollingRelease(ctx context.Context, projectID, teamID string) error { + teamId := c.TeamID(teamID) + url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, projectID, teamId) + + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "DELETE", + url: url, + }, nil) + return err +} diff --git a/docs/data-sources/project_rolling_release.md b/docs/data-sources/project_rolling_release.md new file mode 100644 index 00000000..fca98b14 --- /dev/null +++ b/docs/data-sources/project_rolling_release.md @@ -0,0 +1,47 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_project_rolling_release Data Source - terraform-provider-vercel" +subcategory: "" +description: |- + Data source for a Vercel project rolling release configuration. +--- + +# vercel_project_rolling_release (Data Source) + +Data source for a Vercel project rolling release configuration. + +## Example Usage + +```terraform +data "vercel_project" "example" { + name = "example-project" +} + +data "vercel_project_rolling_release" "example" { + project_id = data.vercel_project.example.id +} +``` + + +## Schema + +### Required + +- `project_id` (String) The ID of the project. + +### Optional + +- `team_id` (String) The ID of the Vercel team. + +### Read-Only + +- `advancement_type` (String) The type of advancement for the rolling release. Either 'automatic' or 'manual-approval'. +- `stages` (Attributes List) The stages for the rolling release configuration. (see [below for nested schema](#nestedatt--stages)) + + +### Nested Schema for `stages` + +Read-Only: + +- `duration` (Number) The duration in minutes to wait before advancing to the next stage. Present for automatic advancement type. +- `target_percentage` (Number) The percentage of traffic to route to this stage. diff --git a/docs/resources/project_rolling_release.md b/docs/resources/project_rolling_release.md new file mode 100644 index 00000000..e1d7bc39 --- /dev/null +++ b/docs/resources/project_rolling_release.md @@ -0,0 +1,57 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_project_rolling_release Resource - terraform-provider-vercel" +subcategory: "" +description: |- + Resource for a Vercel project rolling release configuration. +--- + +# vercel_project_rolling_release (Resource) + +Resource for a Vercel project rolling release configuration. + +## Example Usage + +```terraform +resource "vercel_project" "example" { + name = "example-project" + skew_protection = "12 hours" +} + +resource "vercel_project_rolling_release" "example" { + project_id = vercel_project.example.id + advancement_type = "manual-approval" + stages = [ + { + target_percentage = 20 + }, + { + target_percentage = 50 + } + ] +} +``` + + +## Schema + +### Required + +- `advancement_type` (String) The type of advancement for the rolling release. Must be either 'automatic' or 'manual-approval'. +- `project_id` (String) The ID of the project. +- `stages` (Attributes List) The stages for the rolling release configuration. The last stage must have target_percentage = 100. (see [below for nested schema](#nestedatt--stages)) + +### Optional + +- `team_id` (String) The ID of the Vercel team. + + +### Nested Schema for `stages` + +Required: + +- `target_percentage` (Number) The percentage of traffic to route to this stage. + +Optional: + +- `duration` (Number) The duration in minutes to wait before advancing to the next stage. Required for automatic advancement type. diff --git a/examples/data-sources/vercel_project_rolling_release/data-source.tf b/examples/data-sources/vercel_project_rolling_release/data-source.tf new file mode 100644 index 00000000..ba7bf55a --- /dev/null +++ b/examples/data-sources/vercel_project_rolling_release/data-source.tf @@ -0,0 +1,7 @@ +data "vercel_project" "example" { + name = "example-project" +} + +data "vercel_project_rolling_release" "example" { + project_id = data.vercel_project.example.id +} diff --git a/examples/resources/vercel_project_rolling_release/resource.tf b/examples/resources/vercel_project_rolling_release/resource.tf new file mode 100644 index 00000000..e20e74cb --- /dev/null +++ b/examples/resources/vercel_project_rolling_release/resource.tf @@ -0,0 +1,17 @@ +resource "vercel_project" "example" { + name = "example-project" + skew_protection = "12 hours" +} + +resource "vercel_project_rolling_release" "example" { + project_id = vercel_project.example.id + advancement_type = "manual-approval" + stages = [ + { + target_percentage = 20 + }, + { + target_percentage = 50 + } + ] +} diff --git a/vercel/data_source_project_rolling_release.go b/vercel/data_source_project_rolling_release.go new file mode 100644 index 00000000..57023530 --- /dev/null +++ b/vercel/data_source_project_rolling_release.go @@ -0,0 +1,211 @@ +package vercel + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/vercel/terraform-provider-vercel/v3/client" +) + +var ( + _ datasource.DataSource = &projectRollingReleaseDataSource{} +) + +func newProjectRollingReleaseDataSource() datasource.DataSource { + return &projectRollingReleaseDataSource{} +} + +type projectRollingReleaseDataSource struct { + client *client.Client +} + +func (d *projectRollingReleaseDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_project_rolling_release" +} + +func (d *projectRollingReleaseDataSource) 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 Resource Configure Type", + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + d.client = client +} + +func (d *projectRollingReleaseDataSource) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Data source for a Vercel project rolling release configuration.", + Attributes: map[string]schema.Attribute{ + "project_id": schema.StringAttribute{ + Description: "The ID of the project.", + Required: true, + }, + "team_id": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The ID of the Vercel team.", + }, + "advancement_type": schema.StringAttribute{ + Description: "The type of advancement for the rolling release. Either 'automatic' or 'manual-approval'.", + Computed: true, + }, + "stages": schema.ListNestedAttribute{ + Description: "The stages for the rolling release configuration.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "target_percentage": schema.Int64Attribute{ + Description: "The percentage of traffic to route to this stage.", + Computed: true, + }, + "duration": schema.Int64Attribute{ + Description: "The duration in minutes to wait before advancing to the next stage. Present for automatic advancement type.", + Computed: true, + }, + }, + }, + }, + }, + } +} + +// ProjectRollingReleaseDataSourceModel reflects the structure of the data source. +type ProjectRollingReleaseDataSourceModel struct { + AdvancementType types.String `tfsdk:"advancement_type"` + Stages types.List `tfsdk:"stages"` + ProjectID types.String `tfsdk:"project_id"` + TeamID types.String `tfsdk:"team_id"` +} + +func (d *projectRollingReleaseDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data ProjectRollingReleaseDataSourceModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + var out client.RollingReleaseInfo + var err error + var convertedData ProjectRollingReleaseDataSourceModel + var diags diag.Diagnostics + + out, err = d.client.GetRollingRelease(ctx, data.ProjectID.ValueString(), data.TeamID.ValueString()) + + if err != nil { + resp.Diagnostics.AddError( + "Error reading project rolling release", + fmt.Sprintf("Could not get project rolling release %s %s, unexpected error: %s", + data.TeamID.ValueString(), + data.ProjectID.ValueString(), + err, + ), + ) + return + } + + convertedData, diags = convertResponseToRollingReleaseDataSourceWithConfig(out, data, ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &convertedData)...) +} + +func convertResponseToRollingReleaseDataSourceWithConfig(response client.RollingReleaseInfo, config ProjectRollingReleaseDataSourceModel, ctx context.Context) (ProjectRollingReleaseDataSourceModel, diag.Diagnostics) { + var diags diag.Diagnostics + + result := ProjectRollingReleaseDataSourceModel{ + ProjectID: types.StringValue(response.ProjectID), + TeamID: types.StringValue(response.TeamID), + } + + // Always initialize advancement_type and stages, even if null + result.AdvancementType = types.StringValue("") + result.Stages = types.ListValueMust(RollingReleaseStageElementType, []attr.Value{}) + + // If API has no stages, return empty values + if len(response.RollingRelease.Stages) == 0 { + tflog.Info(ctx, "API has no stages, returning empty values") + return result, diags + } + + // Infer advancement_type if not set + advancementType := response.RollingRelease.AdvancementType + if advancementType == "" { + hasDuration := false + for _, stage := range response.RollingRelease.Stages { + if stage.Duration != nil { + hasDuration = true + break + } + } + if hasDuration { + advancementType = "automatic" + } else { + advancementType = "manual-approval" + } + tflog.Info(ctx, "determined advancement type", map[string]any{ + "determined_advancement_type": advancementType, + "has_duration": hasDuration, + }) + } + result.AdvancementType = types.StringValue(advancementType) + + // Map stages + var rollingReleaseStages []RollingReleaseStage + for _, stage := range response.RollingRelease.Stages { + rollingReleaseStage := RollingReleaseStage{ + TargetPercentage: types.Int64Value(int64(stage.TargetPercentage)), + } + if stage.Duration != nil { + rollingReleaseStage.Duration = types.Int64Value(int64(*stage.Duration)) + } else { + // Set duration to null if not present + rollingReleaseStage.Duration = types.Int64Null() + } + rollingReleaseStages = append(rollingReleaseStages, rollingReleaseStage) + } + // Do NOT add a terminal 100% stage manually! + tflog.Info(ctx, "converted stages", map[string]any{ + "rolling_release_stages_count": len(rollingReleaseStages), + }) + stages := make([]attr.Value, len(rollingReleaseStages)) + for i, stage := range rollingReleaseStages { + stageObj := types.ObjectValueMust( + RollingReleaseStageElementType.AttrTypes, + map[string]attr.Value{ + "target_percentage": stage.TargetPercentage, + "duration": stage.Duration, + }, + ) + stages[i] = stageObj + } + stagesList := types.ListValueMust(RollingReleaseStageElementType, stages) + result.Stages = stagesList + tflog.Info(ctx, "converted rolling release response", map[string]any{ + "advancement_type": advancementType, + "stages_count": len(response.RollingRelease.Stages), + "enabled": response.RollingRelease.Enabled, + "result_advancement_type": result.AdvancementType.ValueString(), + "result_stages_is_null": result.Stages.IsNull(), + "result_stages_length": len(result.Stages.Elements()), + }) + return result, diags +} diff --git a/vercel/data_source_project_rolling_release_test.go b/vercel/data_source_project_rolling_release_test.go new file mode 100644 index 00000000..4b22838d --- /dev/null +++ b/vercel/data_source_project_rolling_release_test.go @@ -0,0 +1,90 @@ +package vercel_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAcc_ProjectRollingReleaseDataSource(t *testing.T) { + nameSuffix := acctest.RandString(16) + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: resource.ComposeAggregateTestCheckFunc( + testAccProjectDestroy(testClient(t), "vercel_project.example", testTeam(t)), + ), + Steps: []resource.TestStep{ + { + Config: cfg(testAccProjectRollingReleasesDataSourceConfig(nameSuffix)), + Check: resource.ComposeAggregateTestCheckFunc( + testAccProjectRollingReleaseExists(testClient(t), "vercel_project_rolling_release.example", testTeam(t)), + resource.TestCheckResourceAttr("vercel_project_rolling_release.example", "advancement_type", "manual-approval"), + resource.TestCheckResourceAttr("vercel_project_rolling_release.example", "stages.#", "3"), + ), + }, + { + Config: cfg(testAccProjectRollingReleasesDataSourceConfigWithDataSource(nameSuffix)), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.vercel_project_rolling_release.example", "advancement_type", "manual-approval"), + resource.TestCheckResourceAttr("data.vercel_project_rolling_release.example", "stages.#", "3"), + ), + }, + }, + }) +} + +func testAccProjectRollingReleasesDataSourceConfig(nameSuffix string) string { + return fmt.Sprintf(` +resource "vercel_project" "example" { + name = "test-acc-example-project-%s" + skew_protection = "12 hours" +} + +resource "vercel_project_rolling_release" "example" { + project_id = vercel_project.example.id + advancement_type = "manual-approval" + stages = [ + { + target_percentage = 20 + }, + { + target_percentage = 50 + }, + { + target_percentage = 100 + } + ] +} +`, nameSuffix) +} + +func testAccProjectRollingReleasesDataSourceConfigWithDataSource(nameSuffix string) string { + return fmt.Sprintf(` +resource "vercel_project" "example" { + name = "test-acc-example-project-%s" + skew_protection = "12 hours" +} + +resource "vercel_project_rolling_release" "example" { + project_id = vercel_project.example.id + advancement_type = "manual-approval" + stages = [ + { + target_percentage = 20 + }, + { + target_percentage = 50 + }, + { + target_percentage = 100 + } + ] +} + +data "vercel_project_rolling_release" "example" { + project_id = vercel_project.example.id +} +`, nameSuffix) +} diff --git a/vercel/provider.go b/vercel/provider.go index da2bf870..ec49186a 100644 --- a/vercel/provider.go +++ b/vercel/provider.go @@ -81,6 +81,7 @@ func (p *vercelProvider) Resources(_ context.Context) []func() resource.Resource newTeamConfigResource, newTeamMemberResource, newWebhookResource, + newProjectRollingReleaseResource, } } @@ -109,6 +110,7 @@ func (p *vercelProvider) DataSources(_ context.Context) []func() datasource.Data newTeamMemberDataSource, newMicrofrontendGroupDataSource, newMicrofrontendGroupMembershipDataSource, + newProjectRollingReleaseDataSource, newDsyncGroupsDataSource, } } diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go new file mode 100644 index 00000000..35fa948d --- /dev/null +++ b/vercel/resource_project_rolling_release.go @@ -0,0 +1,662 @@ +package vercel + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "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/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/vercel/terraform-provider-vercel/v3/client" +) + +var ( + _ resource.Resource = &projectRollingReleaseResource{} + _ resource.ResourceWithConfigure = &projectRollingReleaseResource{} + _ resource.ResourceWithImportState = &projectRollingReleaseResource{} +) + +func newProjectRollingReleaseResource() resource.Resource { + return &projectRollingReleaseResource{} +} + +type projectRollingReleaseResource struct { + client *client.Client +} + +func (r *projectRollingReleaseResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_project_rolling_release" +} + +func (r *projectRollingReleaseResource) 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 +} + +// durationValidator validates that duration is only present when advancement_type is "automatic" +type durationValidator struct{} + +func (v durationValidator) Description(ctx context.Context) string { + return "Duration is required when advancement_type is 'automatic'" +} + +func (v durationValidator) MarkdownDescription(ctx context.Context) string { + return "Duration is required when advancement_type is 'automatic'" +} + +func (v durationValidator) ValidateObject(ctx context.Context, req validator.ObjectRequest, resp *validator.ObjectResponse) { + // Get the parent advancement_type value + parentPath := req.Path.ParentPath() + advancementTypePath := parentPath.ParentPath().AtName("advancement_type") + + var advancementType types.String + diags := req.Config.GetAttribute(ctx, advancementTypePath, &advancementType) + if diags.HasError() { + return + } + + // Check if duration is set + durationAttr, exists := req.ConfigValue.Attributes()["duration"] + if !exists { + return + } + // Check if target_percentage is set + targetPercentageAttr, exists := req.ConfigValue.Attributes()["target_percentage"] + if !exists { + resp.Diagnostics.AddAttributeError( + req.Path.AtName("target_percentage"), + "Invalid target_percentage configuration", + "target_percentage must be set", + ) + } + + duration := durationAttr.(types.Int64) + targetPercentage := targetPercentageAttr.(types.Int64) + if duration.IsNull() || duration.IsUnknown() { + if advancementType.ValueString() == "automatic" && targetPercentage.ValueInt64() != 100 { + resp.Diagnostics.AddAttributeError( + req.Path.AtName("duration"), + "Invalid duration configuration", + "duration must be set when advancement_type is 'automatic'", + ) + } + return + } + + // If duration is set but advancement_type is not "automatic", add an error + if advancementType.ValueString() != "automatic" { + resp.Diagnostics.AddAttributeError( + req.Path.AtName("duration"), + "Invalid duration configuration", + "duration can only be set when advancement_type is 'automatic'", + ) + } +} + +// terminalStageValidator validates that the last stage has target_percentage = 100 +type terminalStageValidator struct{} + +func (v terminalStageValidator) Description(ctx context.Context) string { + return "The last stage must have target_percentage = 100" +} + +func (v terminalStageValidator) MarkdownDescription(ctx context.Context) string { + return "The last stage must have target_percentage = 100" +} + +func (v terminalStageValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + var stages []RollingReleaseStage + diags := req.ConfigValue.ElementsAs(ctx, &stages, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if len(stages) == 0 { + return + } + + // Check that the last stage has target_percentage = 100 + lastStage := stages[len(stages)-1] + if lastStage.TargetPercentage.ValueInt64() != 100 { + resp.Diagnostics.AddAttributeError( + path.Root("stages").AtListIndex(len(stages)-1).AtName("target_percentage"), + "Invalid terminal stage", + "The last stage must have target_percentage = 100", + ) + } + + // Check that no other stage has target_percentage = 100 + for i, stage := range stages[:len(stages)-1] { + if stage.TargetPercentage.ValueInt64() == 100 { + resp.Diagnostics.AddAttributeError( + path.Root("stages").AtListIndex(i).AtName("target_percentage"), + "Invalid stage percentage", + "Only the last stage can have target_percentage = 100", + ) + } + } +} + +// Schema returns the schema information for a project rolling release resource. +func (r *projectRollingReleaseResource) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Resource for a Vercel project rolling release configuration.", + Attributes: map[string]schema.Attribute{ + "project_id": schema.StringAttribute{ + MarkdownDescription: "The ID of the project.", + Required: true, + }, + "team_id": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The ID of the Vercel team.", + }, + "advancement_type": schema.StringAttribute{ + MarkdownDescription: "The type of advancement for the rolling release. Must be either 'automatic' or 'manual-approval'.", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf("automatic", "manual-approval"), + }, + }, + "stages": schema.ListNestedAttribute{ + MarkdownDescription: "The stages for the rolling release configuration. The last stage must have target_percentage = 100.", + Required: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + listvalidator.SizeAtMost(10), + terminalStageValidator{}, + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "target_percentage": schema.Int64Attribute{ + MarkdownDescription: "The percentage of traffic to route to this stage.", + Required: true, + Validators: []validator.Int64{ + int64validator.Between(0, 100), + }, + }, + "duration": schema.Int64Attribute{ + MarkdownDescription: "The duration in minutes to wait before advancing to the next stage. Required for automatic advancement type.", + Optional: true, + Validators: []validator.Int64{ + int64validator.Between(1, 10000), + }, + }, + }, + Validators: []validator.Object{ + durationValidator{}, + }, + }, + }, + }, + } +} + +// ProjectRollingRelease reflects the state terraform stores internally for a project rolling release. +type RollingReleaseInfo struct { + AdvancementType types.String `tfsdk:"advancement_type"` + Stages types.List `tfsdk:"stages"` + ProjectID types.String `tfsdk:"project_id"` + TeamID types.String `tfsdk:"team_id"` +} + +func (e *RollingReleaseInfo) ToCreateRollingReleaseRequest() (client.CreateRollingReleaseRequest, diag.Diagnostics) { + var stages []client.RollingReleaseStage + var diags diag.Diagnostics + + advancementType := e.AdvancementType.ValueString() + + // Convert stages using a more robust approach + var rollingReleaseStages []RollingReleaseStage + diags = e.Stages.ElementsAs(context.Background(), &rollingReleaseStages, false) + + // Add all stages from config (including the terminal 100% stage) + stages = make([]client.RollingReleaseStage, len(rollingReleaseStages)) + for i, stage := range rollingReleaseStages { + clientStage := client.RollingReleaseStage{ + TargetPercentage: int(stage.TargetPercentage.ValueInt64()), + RequireApproval: advancementType == "manual-approval", + } + + if advancementType == "automatic" && !stage.Duration.IsNull() && !stage.Duration.IsUnknown() { + duration := int(stage.Duration.ValueInt64()) + clientStage.Duration = &duration + } + + stages[i] = clientStage + } + + // Log the request for debugging + tflog.Info(context.Background(), "converting to create request", map[string]any{ + "advancement_type": advancementType, + "stages_count": len(stages), + }) + + return client.CreateRollingReleaseRequest{ + RollingRelease: client.RollingRelease{ + Enabled: true, + AdvancementType: advancementType, + Stages: stages, + }, + ProjectID: e.ProjectID.ValueString(), + TeamID: e.TeamID.ValueString(), + }, diags +} + +func (e *RollingReleaseInfo) toUpdateRollingReleaseRequest() (client.UpdateRollingReleaseRequest, diag.Diagnostics) { + var stages []client.RollingReleaseStage + var diags diag.Diagnostics + + advancementType := e.AdvancementType.ValueString() + + // Convert stages using a more robust approach + var rollingReleaseStages []RollingReleaseStage + diags = e.Stages.ElementsAs(context.Background(), &rollingReleaseStages, false) + + // Add all stages from config (including the terminal 100% stage) + stages = make([]client.RollingReleaseStage, len(rollingReleaseStages)) + for i, stage := range rollingReleaseStages { + clientStage := client.RollingReleaseStage{ + TargetPercentage: int(stage.TargetPercentage.ValueInt64()), + RequireApproval: advancementType == "manual-approval", + } + + // Add duration for automatic advancement type + if advancementType == "automatic" && !stage.Duration.IsNull() && !stage.Duration.IsUnknown() { + duration := int(stage.Duration.ValueInt64()) + clientStage.Duration = &duration + } + + stages[i] = clientStage + } + + // Log the request for debugging + tflog.Info(context.Background(), "converting to update request", map[string]any{ + "advancement_type": advancementType, + "stages_count": len(stages), + }) + + return client.UpdateRollingReleaseRequest{ + RollingRelease: client.RollingRelease{ + Enabled: true, + AdvancementType: advancementType, + Stages: stages, + }, + ProjectID: e.ProjectID.ValueString(), + TeamID: e.TeamID.ValueString(), + }, diags +} + +func ConvertResponseToRollingRelease(response client.RollingReleaseInfo, plan *RollingReleaseInfo, ctx context.Context) (RollingReleaseInfo, diag.Diagnostics) { + var diags diag.Diagnostics + + result := RollingReleaseInfo{ + ProjectID: types.StringValue(response.ProjectID), + TeamID: types.StringValue(response.TeamID), + } + + // If disabled or advancementType is empty, check if we have stages to determine if it's configured + if !response.RollingRelease.Enabled || response.RollingRelease.AdvancementType == "" { + if plan != nil && + !plan.AdvancementType.IsNull() && plan.AdvancementType.ValueString() != "" && + !plan.Stages.IsNull() && len(plan.Stages.Elements()) > 0 { + + result.AdvancementType = plan.AdvancementType + result.Stages = plan.Stages + return result, diags + } + + if len(response.RollingRelease.Stages) > 0 { + advancementType := "manual-approval" + for _, stage := range response.RollingRelease.Stages { + if stage.Duration != nil { + advancementType = "automatic" + break + } + } + result.AdvancementType = types.StringValue(advancementType) + + var rollingReleaseStages []RollingReleaseStage + for _, stage := range response.RollingRelease.Stages { + rollingReleaseStage := RollingReleaseStage{ + TargetPercentage: types.Int64Value(int64(stage.TargetPercentage)), + } + if stage.Duration != nil { + rollingReleaseStage.Duration = types.Int64Value(int64(*stage.Duration)) + } else { + rollingReleaseStage.Duration = types.Int64Null() + } + rollingReleaseStages = append(rollingReleaseStages, rollingReleaseStage) + } + // Do NOT add a terminal 100% stage manually! + stages := make([]attr.Value, len(rollingReleaseStages)) + for i, stage := range rollingReleaseStages { + stageObj := types.ObjectValueMust( + RollingReleaseStageElementType.AttrTypes, + map[string]attr.Value{ + "target_percentage": stage.TargetPercentage, + "duration": stage.Duration, + }, + ) + stages[i] = stageObj + } + stagesList := types.ListValueMust(RollingReleaseStageElementType, stages) + result.Stages = stagesList + } else { + result.AdvancementType = types.StringNull() + result.Stages = types.ListNull(RollingReleaseStageElementType) + } + return result, diags + } + + result.AdvancementType = types.StringValue(response.RollingRelease.AdvancementType) + + var rollingReleaseStages []RollingReleaseStage + for _, stage := range response.RollingRelease.Stages { + rollingReleaseStage := RollingReleaseStage{ + TargetPercentage: types.Int64Value(int64(stage.TargetPercentage)), + } + if stage.Duration != nil { + rollingReleaseStage.Duration = types.Int64Value(int64(*stage.Duration)) + } else { + rollingReleaseStage.Duration = types.Int64Null() + } + rollingReleaseStages = append(rollingReleaseStages, rollingReleaseStage) + } + // Do NOT add a terminal 100% stage manually! + stages := make([]attr.Value, len(rollingReleaseStages)) + for i, stage := range rollingReleaseStages { + stageObj := types.ObjectValueMust( + RollingReleaseStageElementType.AttrTypes, + map[string]attr.Value{ + "target_percentage": stage.TargetPercentage, + "duration": stage.Duration, + }, + ) + stages[i] = stageObj + } + stagesList := types.ListValueMust(RollingReleaseStageElementType, stages) + result.Stages = stagesList + // Log the conversion result for debugging + tflog.Info(ctx, "converted rolling release response", map[string]any{ + "advancement_type": response.RollingRelease.AdvancementType, + "stages_count": len(response.RollingRelease.Stages), + "api_stages": response.RollingRelease.Stages, + "converted_stages_count": len(rollingReleaseStages), + "converted_stages": rollingReleaseStages, + "final_stages_count": len(stages), + "final_stages": stages, + "stages_list_type": fmt.Sprintf("%T", stagesList), + "stages_list_elements": stagesList.Elements(), + }) + return result, diags +} + +// Create will create a rolling release for a Vercel project by sending a request to the Vercel API. +func (r *projectRollingReleaseResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan RollingReleaseInfo + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // First, check if a rolling release already exists + existingRelease, err := r.client.GetRollingRelease(ctx, plan.ProjectID.ValueString(), plan.TeamID.ValueString()) + if err != nil && !client.NotFound(err) { + resp.Diagnostics.AddError( + "Error checking existing project rolling release", + fmt.Sprintf("Could not check if project rolling release exists, unexpected error: %s", + err, + ), + ) + return + } + + // If a rolling release already exists and is enabled, return an error + if err == nil && existingRelease.RollingRelease.Enabled { + resp.Diagnostics.AddError( + "Project rolling release already exists", + fmt.Sprintf("A rolling release is already configured for project %s. To change an existing configuration, you must import the rolling release or remove the existing configuration.", + plan.ProjectID.ValueString(), + ), + ) + return + } + + // Convert plan to client request + request, diags := plan.ToCreateRollingReleaseRequest() + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Log the request for debugging + tflog.Info(ctx, "creating rolling release", map[string]any{ + "enabled": request.RollingRelease.Enabled, + "advancement_type": request.RollingRelease.AdvancementType, + "stages": request.RollingRelease.Stages, + }) + + out, err := r.client.CreateRollingRelease(ctx, request) + if err != nil { + resp.Diagnostics.AddError( + "Error creating project rolling release", + fmt.Sprintf("Could not create project rolling release, unexpected error: %s", + err, + ), + ) + return + } + + // Convert response to state + result, diags := ConvertResponseToRollingRelease(out, &plan, ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Log the result for debugging + tflog.Debug(ctx, "created rolling release", map[string]any{ + "project_id": result.ProjectID.ValueString(), + }) + + // Set state + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Read will read a rolling release of a Vercel project by requesting it from the Vercel API, and will update terraform +// with this information. +func (r *projectRollingReleaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state RollingReleaseInfo + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.client.GetRollingRelease(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 rolling release", + fmt.Sprintf("Could not get project rolling release, unexpected error: %s", + err, + ), + ) + return + } + + // Log the response for debugging + tflog.Debug(ctx, "got rolling release from API", map[string]any{ + "enabled": out.RollingRelease.Enabled, + "advancement_type": out.RollingRelease.AdvancementType, + "stages": out.RollingRelease.Stages, + }) + + result, diags := ConvertResponseToRollingRelease(out, &state, ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Log the result for debugging + tflog.Debug(ctx, "converted rolling release", map[string]any{ + "project_id": result.ProjectID.ValueString(), + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Update will update an existing rolling release to the latest information. +func (r *projectRollingReleaseResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan RollingReleaseInfo + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Convert plan to client request + request, diags := plan.toUpdateRollingReleaseRequest() + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Log the request for debugging + tflog.Debug(ctx, "updating rolling release", map[string]any{ + "enabled": request.RollingRelease.Enabled, + "advancement_type": request.RollingRelease.AdvancementType, + "stages": request.RollingRelease.Stages, + }) + + out, err := r.client.UpdateRollingRelease(ctx, request) + if err != nil { + resp.Diagnostics.AddError( + "Error updating project rolling release", + fmt.Sprintf("Could not update project rolling release, unexpected error: %s", + err, + ), + ) + return + } + + // Convert response to state + result, diags := ConvertResponseToRollingRelease(out, &plan, ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Log the result for debugging + tflog.Debug(ctx, "updated rolling release", map[string]any{ + "project_id": result.ProjectID.ValueString(), + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Delete will delete an existing rolling release by disabling it. +func (r *projectRollingReleaseResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state RollingReleaseInfo + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteRollingRelease(ctx, state.ProjectID.ValueString(), state.TeamID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Error deleting project rolling release", + fmt.Sprintf("Could not delete project rolling release, unexpected error: %s", + err, + ), + ) + return + } +} + +// ImportState takes an identifier and reads all the project rolling release information from the Vercel API. +// The results are then stored in terraform state. +func (r *projectRollingReleaseResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + teamID, projectID, ok := splitInto1Or2(req.ID) + if !ok { + resp.Diagnostics.AddError( + "Error importing project rolling release", + fmt.Sprintf("Invalid id '%s' specified. should be in format \"team_id/project_id\" or \"project_id\"", req.ID), + ) + } + + out, err := r.client.GetRollingRelease(ctx, projectID, teamID) + if err != nil { + resp.Diagnostics.AddError( + "Error reading project rolling release", + fmt.Sprintf("Could not get project rolling release %s %s, unexpected error: %s", + teamID, + projectID, + err, + ), + ) + return + } + + // For import, we don't have any state to preserve + result, diags := ConvertResponseToRollingRelease(out, nil, ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "imported project rolling release", map[string]any{ + "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_rolling_release_test.go b/vercel/resource_project_rolling_release_test.go new file mode 100644 index 00000000..916da779 --- /dev/null +++ b/vercel/resource_project_rolling_release_test.go @@ -0,0 +1,251 @@ +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/v3/client" +) + +func getRollingReleaseImportId(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) + } + + return fmt.Sprintf("%s/%s", rs.Primary.Attributes["team_id"], rs.Primary.Attributes["project_id"]), nil + } +} + +func testAccProjectRollingReleaseExists(testClient *client.Client, 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.GetRollingRelease(context.TODO(), rs.Primary.Attributes["project_id"], teamID) + return err + } +} + +func TestAcc_ProjectRollingRelease(t *testing.T) { + resourceName := "vercel_project_rolling_release.example" + nameSuffix := acctest.RandString(16) + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: resource.ComposeAggregateTestCheckFunc( + testAccProjectDestroy(testClient(t), "vercel_project.example", testTeam(t)), + ), + Steps: []resource.TestStep{ + { + Config: cfg(testAccProjectRollingReleasesConfig(nameSuffix)), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("vercel_project.example", "id"), + testAccProjectRollingReleaseExists(testClient(t), resourceName, testTeam(t)), + resource.TestCheckResourceAttr(resourceName, "advancement_type", "manual-approval"), + resource.TestCheckResourceAttr(resourceName, "stages.#", "3"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "stages.*", map[string]string{ + "target_percentage": "20", + }), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "stages.*", map[string]string{ + "target_percentage": "50", + }), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "stages.*", map[string]string{ + "target_percentage": "100", + }), + ), + }, + // Now, import the existing resource + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: getRollingReleaseImportId(resourceName), + ImportStateVerifyIdentifierAttribute: "project_id", + }, + // Then update to new configuration + { + Config: cfg(testAccProjectRollingReleasesConfigUpdated(nameSuffix)), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("vercel_project.example", "id"), + testAccProjectRollingReleaseExists(testClient(t), resourceName, testTeam(t)), + resource.TestCheckResourceAttr(resourceName, "advancement_type", "manual-approval"), + resource.TestCheckResourceAttr(resourceName, "stages.#", "4"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "stages.*", map[string]string{ + "target_percentage": "20", + }), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "stages.*", map[string]string{ + "target_percentage": "50", + }), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "stages.*", map[string]string{ + "target_percentage": "80", + }), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "stages.*", map[string]string{ + "target_percentage": "100", + }), + ), + }, + // Then update to new configuration + { + Config: cfg(testAccProjectRollingReleasesConfigUpdatedAutomatic(nameSuffix)), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("vercel_project.example", "id"), + testAccProjectRollingReleaseExists(testClient(t), resourceName, testTeam(t)), + resource.TestCheckResourceAttr(resourceName, "advancement_type", "automatic"), + resource.TestCheckResourceAttr(resourceName, "stages.#", "4"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "stages.*", map[string]string{ + "target_percentage": "20", + "duration": "10", + }), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "stages.*", map[string]string{ + "target_percentage": "50", + "duration": "10", + }), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "stages.*", map[string]string{ + "target_percentage": "80", + "duration": "10", + }), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "stages.*", map[string]string{ + "target_percentage": "100", + }), + ), + }, + { + Config: cfg(fmt.Sprintf(` + resource "vercel_project" "example" { + name = "test-acc-rr-auto-duration-%s" + skew_protection = "12 hours" + } + resource "vercel_project_rolling_release" "example" { + project_id = vercel_project.example.id + advancement_type = "automatic" + stages = [ + { + target_percentage = 30 + duration = 60 // Explicit duration for a middle stage + }, + { + target_percentage = 70 + duration = 30 // Explicit duration for a middle stage + }, + { + target_percentage = 100 + // Duration for the last stage is expected to be null or not present + } + ] + } + `, nameSuffix)), + Check: resource.ComposeAggregateTestCheckFunc( + testAccProjectRollingReleaseExists(testClient(t), resourceName, testTeam(t)), + resource.TestCheckResourceAttr(resourceName, "advancement_type", "automatic"), + resource.TestCheckResourceAttr(resourceName, "stages.#", "3"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "stages.*", map[string]string{ + "target_percentage": "30", + "duration": "60", // Asserting the default value + }), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "stages.*", map[string]string{ + "target_percentage": "70", + "duration": "30", // Asserting the explicit value + }), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "stages.*", map[string]string{ + "target_percentage": "100", + // Duration for the last stage is expected to be null or not present + }), + ), + }, + }, + }) +} + +func testAccProjectRollingReleasesConfig(nameSuffix string) string { + return fmt.Sprintf(` +resource "vercel_project" "example" { + name = "test-acc-rolling-releases-%s" + skew_protection = "12 hours" +} + +resource "vercel_project_rolling_release" "example" { + project_id = vercel_project.example.id + advancement_type = "manual-approval" + stages = [ + { + target_percentage = 20 + }, + { + target_percentage = 50 + }, + { + target_percentage = 100 + } + ] +} +`, nameSuffix) +} + +func testAccProjectRollingReleasesConfigUpdated(nameSuffix string) string { + return fmt.Sprintf(` +resource "vercel_project" "example" { + name = "test-acc-rolling-releases-%s" + skew_protection = "12 hours" +} + +resource "vercel_project_rolling_release" "example" { + project_id = vercel_project.example.id + advancement_type = "manual-approval" + stages = [ + { + target_percentage = 20 + }, + { + target_percentage = 50 + }, + { + target_percentage = 80 + }, + { + target_percentage = 100 + } + ] +} +`, nameSuffix) +} +func testAccProjectRollingReleasesConfigUpdatedAutomatic(nameSuffix string) string { + return fmt.Sprintf(` +resource "vercel_project" "example" { + name = "test-acc-rolling-releases-%s" + skew_protection = "12 hours" +} + +resource "vercel_project_rolling_release" "example" { + project_id = vercel_project.example.id + advancement_type = "automatic" + stages = [ + { + target_percentage = 20 + duration = 10 + }, + { + target_percentage = 50 + duration = 10 + }, + { + target_percentage = 80 + duration = 10 + }, + { + target_percentage = 100 + } + ] +} +`, nameSuffix) +} diff --git a/vercel/rolling_release_types.go b/vercel/rolling_release_types.go new file mode 100644 index 00000000..cfd81934 --- /dev/null +++ b/vercel/rolling_release_types.go @@ -0,0 +1,20 @@ +package vercel + +import ( + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Define the element type for stages +var RollingReleaseStageElementType = types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "target_percentage": types.Int64Type, + "duration": types.Int64Type, + }, +} + +// Define the stage type +type RollingReleaseStage struct { + TargetPercentage types.Int64 `tfsdk:"target_percentage"` + Duration types.Int64 `tfsdk:"duration"` +}