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"`
+}