diff --git a/client/project.go b/client/project.go
index 9c84a9be..2433b261 100644
--- a/client/project.go
+++ b/client/project.go
@@ -210,6 +210,7 @@ type ProjectResponse struct {
ResourceConfig *ResourceConfigResponse `json:"resourceConfig"`
NodeVersion string `json:"nodeVersion"`
Crons *ProjectCronsResponse `json:"crons"`
+ RollingRelease *RollingRelease `json:"rollingRelease"`
}
type ProjectCronsResponse struct {
diff --git a/client/project_rolling_release.go b/client/project_rolling_release.go
new file mode 100644
index 00000000..d9fd04cc
--- /dev/null
+++ b/client/project_rolling_release.go
@@ -0,0 +1,127 @@
+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
+}
+
+// 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)
+ if request.RollingRelease.Enabled {
+ 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, "enabled rolling release", map[string]any{
+ "response": result,
+ "request": request,
+ })
+ return result, nil
+ } else {
+ // For disabling, just send the request as is
+ disabledRequest := UpdateRollingReleaseRequest{
+ RollingRelease: RollingRelease{
+ Enabled: false,
+ AdvancementType: "",
+ Stages: []RollingReleaseStage{},
+ },
+ }
+
+ 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(disabledRequest.RollingRelease)),
+ }, &result)
+ if err != nil {
+ return RollingReleaseInfo{}, fmt.Errorf("error disabling rolling release: %w", err)
+ }
+
+ result.ProjectID = request.ProjectID
+ result.TeamID = request.TeamID
+
+ 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..959d02db
--- /dev/null
+++ b/docs/data-sources/project_rolling_release.md
@@ -0,0 +1,56 @@
+---
+# 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_rolling_release.example.id
+}
+```
+
+
+## Schema
+
+### Required
+
+- `project_id` (String) The ID of the project.
+
+### Optional
+
+- `rolling_release` (Attributes) The rolling release configuration. (see [below for nested schema](#nestedatt--rolling_release))
+- `team_id` (String) The ID of the Vercel team.
+
+
+### Nested Schema for `rolling_release`
+
+Read-Only:
+
+- `advancement_type` (String) The type of advancement between stages. Must be either 'automatic' or 'manual-approval'. Required when enabled is true.
+- `enabled` (Boolean) Whether rolling releases are enabled.
+- `stages` (Attributes List) The stages of the rolling release. Required when enabled is true. (see [below for nested schema](#nestedatt--rolling_release--stages))
+
+
+### Nested Schema for `rolling_release.stages`
+
+Optional:
+
+- `duration` (Number) The duration in minutes to wait before advancing to the next stage. Required for all stages except the final stage when using automatic advancement.
+
+Read-Only:
+
+- `require_approval` (Boolean) Whether approval is required before advancing to the next stage.
+- `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..8eb1142e
--- /dev/null
+++ b/docs/resources/project_rolling_release.md
@@ -0,0 +1,81 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "vercel_project_rolling_release Resource - terraform-provider-vercel"
+subcategory: ""
+description: |-
+ Manages rolling release configuration for a Vercel project.
+---
+
+# vercel_project_rolling_release (Resource)
+
+Manages rolling release configuration for a Vercel project.
+
+## 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
+ rolling_release = {
+ enabled = true
+ advancement_type = "manual-approval"
+ stages = [
+ {
+ require_approval = true
+ target_percentage = 20
+ },
+ {
+ require_approval = true
+ target_percentage = 50
+ },
+ {
+ require_approval = true
+ target_percentage = 100
+ }
+ ]
+ }
+}
+```
+
+
+## Schema
+
+### Required
+
+- `project_id` (String) The ID of the project.
+- `rolling_release` (Attributes) The rolling release configuration. (see [below for nested schema](#nestedatt--rolling_release))
+
+### Optional
+
+- `team_id` (String) The ID of the Vercel team.
+
+
+### Nested Schema for `rolling_release`
+
+Required:
+
+- `enabled` (Boolean) Whether rolling releases are enabled.
+
+Optional:
+
+- `advancement_type` (String) The type of advancement between stages. Must be either 'automatic' or 'manual-approval'. Required when enabled is true.
+- `stages` (Attributes List) The stages of the rolling release. Required when enabled is true. (see [below for nested schema](#nestedatt--rolling_release--stages))
+
+
+### Nested Schema for `rolling_release.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 non-last stages when advancement_type is 'automatic'.
+
+Read-Only:
+
+- `require_approval` (Boolean) Whether approval is required before advancing to the next stage.
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..e3b96582
--- /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_rolling_release.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..e895ed1f
--- /dev/null
+++ b/examples/resources/vercel_project_rolling_release/resource.tf
@@ -0,0 +1,26 @@
+resource "vercel_project" "example" {
+ name = "example-project"
+ skew_protection = "12 hours"
+}
+
+resource "vercel_project_rolling_release" "example" {
+ project_id = vercel_project.example.id
+ rolling_release = {
+ enabled = true
+ advancement_type = "manual-approval"
+ stages = [
+ {
+ require_approval = true
+ target_percentage = 20
+ },
+ {
+ require_approval = true
+ target_percentage = 50
+ },
+ {
+ require_approval = true
+ target_percentage = 100
+ }
+ ]
+ }
+}
diff --git a/vercel/data_source_project_rolling_release.go b/vercel/data_source_project_rolling_release.go
new file mode 100644
index 00000000..751b1255
--- /dev/null
+++ b/vercel/data_source_project_rolling_release.go
@@ -0,0 +1,208 @@
+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/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{
+ MarkdownDescription: "The ID of the project.",
+ Required: true,
+ },
+ "team_id": schema.StringAttribute{
+ Optional: true,
+ Computed: true,
+ Description: "The ID of the Vercel team.",
+ },
+ "rolling_release": schema.SingleNestedAttribute{
+ MarkdownDescription: "The rolling release configuration.",
+ Computed: true,
+ Optional: true,
+ Attributes: map[string]schema.Attribute{
+ "enabled": schema.BoolAttribute{
+ MarkdownDescription: "Whether rolling releases are enabled.",
+ Computed: true,
+ },
+ "advancement_type": schema.StringAttribute{
+ MarkdownDescription: "The type of advancement between stages. Must be either 'automatic' or 'manual-approval'. Required when enabled is true.",
+ Computed: true,
+ },
+ "stages": schema.ListNestedAttribute{
+ MarkdownDescription: "The stages of the rolling release. Required when enabled is true.",
+ Computed: true,
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "target_percentage": schema.Int64Attribute{
+ MarkdownDescription: "The percentage of traffic to route to this stage.",
+ Computed: true,
+ },
+ "duration": schema.Int64Attribute{
+ MarkdownDescription: "The duration in minutes to wait before advancing to the next stage. Required for all stages except the final stage when using automatic advancement.",
+ Optional: true,
+ },
+ "require_approval": schema.BoolAttribute{
+ MarkdownDescription: "Whether approval is required before advancing to the next stage.",
+ Computed: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+}
+
+type RollingReleaseStageDataSource struct {
+ TargetPercentage types.Int64 `tfsdk:"target_percentage"`
+ Duration types.Int64 `tfsdk:"duration"`
+ RequireApproval types.Bool `tfsdk:"require_approval"`
+}
+
+type RollingReleaseDataSource struct {
+ Enabled types.Bool `tfsdk:"enabled"`
+ AdvancementType types.String `tfsdk:"advancement_type"`
+ Stages []RollingReleaseStageDataSource `tfsdk:"stages"`
+}
+
+type RollingReleaseInfoDataSource struct {
+ RollingRelease types.Object `tfsdk:"rolling_release"`
+ ProjectID types.String `tfsdk:"project_id"`
+ TeamID types.String `tfsdk:"team_id"`
+}
+
+func convertStagesDataSource(stages []client.RollingReleaseStage) []RollingReleaseStageDataSource {
+ if len(stages) == 0 {
+ return []RollingReleaseStageDataSource{}
+ }
+
+ result := make([]RollingReleaseStageDataSource, len(stages))
+ for i, stage := range stages {
+ duration := types.Int64Null()
+ if stage.Duration != nil {
+ duration = types.Int64Value(int64(*stage.Duration))
+ }
+
+ result[i] = RollingReleaseStageDataSource{
+ TargetPercentage: types.Int64Value(int64(stage.TargetPercentage)),
+ Duration: duration,
+ RequireApproval: types.BoolValue(stage.RequireApproval),
+ }
+ }
+ return result
+}
+
+func convertResponseToRollingReleaseDataSource(response client.RollingReleaseInfo) RollingReleaseInfoDataSource {
+ rollingRelease := RollingReleaseDataSource{
+ Enabled: types.BoolValue(response.RollingRelease.Enabled),
+ AdvancementType: types.StringValue(response.RollingRelease.AdvancementType),
+ Stages: convertStagesDataSource(response.RollingRelease.Stages),
+ }
+
+ if !response.RollingRelease.Enabled {
+ rollingRelease.AdvancementType = types.StringValue("")
+ rollingRelease.Stages = make([]RollingReleaseStageDataSource, 0)
+ }
+
+ rollingReleaseObj, _ := types.ObjectValueFrom(context.Background(), map[string]attr.Type{
+ "enabled": types.BoolType,
+ "advancement_type": types.StringType,
+ "stages": types.ListType{ElemType: types.ObjectType{AttrTypes: map[string]attr.Type{
+ "target_percentage": types.Int64Type,
+ "duration": types.Int64Type,
+ "require_approval": types.BoolType,
+ }}},
+ }, rollingRelease)
+
+ return RollingReleaseInfoDataSource{
+ RollingRelease: rollingReleaseObj,
+ ProjectID: types.StringValue(response.ProjectID),
+ TeamID: types.StringValue(response.TeamID),
+ }
+}
+
+func (d *projectRollingReleaseDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
+ var config RollingReleaseInfoDataSource
+ diags := req.Config.Get(ctx, &config)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ out, err := d.client.GetRollingRelease(ctx, config.ProjectID.ValueString(), config.TeamID.ValueString())
+ if client.NotFound(err) {
+ resp.Diagnostics.AddError(
+ "Error reading project rolling release",
+ fmt.Sprintf("No project rolling release found with id %s %s", config.TeamID.ValueString(), config.ProjectID.ValueString()),
+ )
+ return
+ }
+ if err != nil {
+ resp.Diagnostics.AddError(
+ "Error reading project rolling release",
+ fmt.Sprintf("Could not get project rolling release %s %s, unexpected error: %s",
+ config.TeamID.ValueString(),
+ config.ProjectID.ValueString(),
+ err,
+ ),
+ )
+ return
+ }
+
+ result := convertResponseToRollingReleaseDataSource(out)
+ tflog.Info(ctx, "read 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/data_source_project_rolling_release_test.go b/vercel/data_source_project_rolling_release_test.go
new file mode 100644
index 00000000..29c6e7e6
--- /dev/null
+++ b/vercel/data_source_project_rolling_release_test.go
@@ -0,0 +1,51 @@
+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(testAccProjectRollingReleasesConfigOffWithDataSource(nameSuffix)),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ testAccProjectRollingReleaseExists(testClient(t), "vercel_project_rolling_release.example", testTeam(t)),
+ resource.TestCheckResourceAttr("data.vercel_project_rolling_release.example", "rolling_release.enabled", "false"),
+ resource.TestCheckResourceAttr("data.vercel_project_rolling_release.example", "rolling_release.advancement_type", ""),
+ resource.TestCheckResourceAttr("data.vercel_project_rolling_release.example", "rolling_release.stages.#", "0"),
+ ),
+ },
+ },
+ })
+}
+
+func testAccProjectRollingReleasesConfigOffWithDataSource(nameSuffix string) string {
+ return fmt.Sprintf(`
+resource "vercel_project" "example" {
+ name = "test-acc-example-project-%s"
+}
+
+resource "vercel_project_rolling_release" "example" {
+ project_id = vercel_project.example.id
+ rolling_release = {
+ enabled = false
+ advancement_type = ""
+ stages = []
+ }
+}
+
+data "vercel_project_rolling_release" "example" {
+ project_id = vercel_project_rolling_release.example.project_id
+}
+`, nameSuffix)
+}
diff --git a/vercel/provider.go b/vercel/provider.go
index a7cc8637..7893eeab 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,
}
}
diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go
new file mode 100644
index 00000000..704c4366
--- /dev/null
+++ b/vercel/resource_project_rolling_release.go
@@ -0,0 +1,664 @@
+package vercel
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
+ "github.com/hashicorp/terraform-plugin-framework/attr"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+ "github.com/vercel/terraform-provider-vercel/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
+}
+
+// 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: "Manages rolling release configuration for a Vercel project.",
+ 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.",
+ PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplaceIfConfigured(), stringplanmodifier.UseStateForUnknown()},
+ },
+ "rolling_release": schema.SingleNestedAttribute{
+ MarkdownDescription: "The rolling release configuration.",
+ Required: true,
+ Attributes: map[string]schema.Attribute{
+ "enabled": schema.BoolAttribute{
+ MarkdownDescription: "Whether rolling releases are enabled.",
+ Required: true,
+ },
+ "advancement_type": schema.StringAttribute{
+ MarkdownDescription: "The type of advancement between stages. Must be either 'automatic' or 'manual-approval'. Required when enabled is true.",
+ Optional: true,
+ Computed: true,
+ Validators: []validator.String{
+ advancementTypeValidator{},
+ },
+ },
+ "stages": schema.ListNestedAttribute{
+ MarkdownDescription: "The stages of the rolling release. Required when enabled is true.",
+ Optional: true,
+ Computed: true,
+ 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 non-last stages when advancement_type is 'automatic'.",
+ Optional: true,
+ Validators: []validator.Int64{
+ int64validator.Between(0, 999),
+ },
+ },
+ "require_approval": schema.BoolAttribute{
+ MarkdownDescription: "Whether approval is required before advancing to the next stage.",
+ Computed: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+}
+
+type RollingReleaseStage struct {
+ TargetPercentage types.Int64 `tfsdk:"target_percentage"`
+ Duration types.Int64 `tfsdk:"duration"`
+ RequireApproval types.Bool `tfsdk:"require_approval"`
+}
+
+// RollingRelease reflects the state terraform stores internally for a project rolling release.
+type RollingRelease struct {
+ Enabled types.Bool `tfsdk:"enabled"`
+ AdvancementType types.String `tfsdk:"advancement_type"`
+ Stages types.List `tfsdk:"stages"`
+}
+
+// ProjectRollingRelease reflects the state terraform stores internally for a project rolling release.
+type RollingReleaseInfo struct {
+ RollingRelease RollingRelease `tfsdk:"rolling_release"`
+ ProjectID types.String `tfsdk:"project_id"`
+ TeamID types.String `tfsdk:"team_id"`
+}
+
+func (e *RollingReleaseInfo) toUpdateRollingReleaseRequest() (client.UpdateRollingReleaseRequest, diag.Diagnostics) {
+ var stages []client.RollingReleaseStage
+ var advancementType string
+ var diags diag.Diagnostics
+
+ if e.RollingRelease.Enabled.ValueBool() {
+ advancementType = e.RollingRelease.AdvancementType.ValueString()
+
+ // Convert stages from types.List to []client.RollingReleaseStage
+ var tfStages []RollingReleaseStage
+ diags = e.RollingRelease.Stages.ElementsAs(context.Background(), &tfStages, false)
+ if diags.HasError() {
+ return client.UpdateRollingReleaseRequest{}, diags
+ }
+
+ stages = make([]client.RollingReleaseStage, len(tfStages))
+ for i, stage := range tfStages {
+ if advancementType == "automatic" {
+ // For automatic advancement, duration is required except for last stage
+ if i < len(tfStages)-1 {
+ // Non-last stage needs duration
+ if stage.Duration.IsNull() {
+ // Default duration for non-last stages
+ duration := 60
+ stages[i] = client.RollingReleaseStage{
+ TargetPercentage: int(stage.TargetPercentage.ValueInt64()),
+ Duration: &duration,
+ RequireApproval: stage.RequireApproval.ValueBool(),
+ }
+ } else {
+ duration := int(stage.Duration.ValueInt64())
+ stages[i] = client.RollingReleaseStage{
+ TargetPercentage: int(stage.TargetPercentage.ValueInt64()),
+ Duration: &duration,
+ RequireApproval: stage.RequireApproval.ValueBool(),
+ }
+ }
+ } else {
+ // Last stage should not have duration
+ stages[i] = client.RollingReleaseStage{
+ TargetPercentage: int(stage.TargetPercentage.ValueInt64()),
+ RequireApproval: stage.RequireApproval.ValueBool(),
+ }
+ }
+ } else {
+ // For manual approval, duration is not used
+ stages[i] = client.RollingReleaseStage{
+ TargetPercentage: int(stage.TargetPercentage.ValueInt64()),
+ RequireApproval: stage.RequireApproval.ValueBool(),
+ }
+ }
+ }
+ } else {
+ // When disabled, don't send any stages or advancement type to the API
+ stages = []client.RollingReleaseStage{}
+ advancementType = ""
+ }
+
+ // Log the request for debugging
+ tflog.Info(context.Background(), "converting to update request", map[string]any{
+ "enabled": e.RollingRelease.Enabled.ValueBool(),
+ "advancement_type": advancementType,
+ "stages_count": len(stages),
+ })
+
+ return client.UpdateRollingReleaseRequest{
+ RollingRelease: client.RollingRelease{
+ Enabled: e.RollingRelease.Enabled.ValueBool(),
+ 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
+ advancementType := types.StringNull()
+ if plan.RollingRelease.Enabled.ValueBool() {
+ advancementType = plan.RollingRelease.AdvancementType
+ }
+ result := RollingReleaseInfo{
+ RollingRelease: RollingRelease{
+ Enabled: plan.RollingRelease.Enabled,
+ AdvancementType: advancementType,
+ },
+ ProjectID: types.StringValue(response.ProjectID),
+ TeamID: types.StringValue(response.TeamID),
+ }
+
+ // If disabled, return empty values
+ if !plan.RollingRelease.Enabled.ValueBool() {
+ result.RollingRelease.AdvancementType = types.StringValue("")
+ // Create an empty list instead of null
+ emptyStages, stagesDiags := types.ListValueFrom(ctx, types.ObjectType{
+ AttrTypes: map[string]attr.Type{
+ "target_percentage": types.Int64Type,
+ "duration": types.Int64Type,
+ "require_approval": types.BoolType,
+ },
+ }, []attr.Value{})
+ diags.Append(stagesDiags...)
+ if diags.HasError() {
+ return result, diags
+ }
+ result.RollingRelease.Stages = emptyStages
+ return result, diags
+ }
+
+ // If we have a plan, try to match stages by target percentage to preserve order
+ var orderedStages []client.RollingReleaseStage
+ if plan != nil && !plan.RollingRelease.Stages.IsNull() {
+ var planStages []RollingReleaseStage
+ diags = plan.RollingRelease.Stages.ElementsAs(ctx, &planStages, false)
+ if diags.HasError() {
+ return result, diags
+ }
+
+ // Create a map of target percentage to stage for quick lookup
+ stageMap := make(map[int]client.RollingReleaseStage)
+ for _, stage := range response.RollingRelease.Stages {
+ stageMap[stage.TargetPercentage] = stage
+ }
+
+ // Match stages by target percentage
+ orderedStages = make([]client.RollingReleaseStage, len(planStages))
+ for i, planStage := range planStages {
+ targetPercentage := int(planStage.TargetPercentage.ValueInt64())
+ if stage, ok := stageMap[targetPercentage]; ok {
+ orderedStages[i] = stage
+ }
+ }
+ } else {
+ orderedStages = response.RollingRelease.Stages
+ }
+
+ // Convert stages to Terraform types
+ stages := make([]attr.Value, len(orderedStages))
+ for i, stage := range orderedStages {
+ var duration types.Int64
+ if plan != nil && !plan.RollingRelease.Stages.IsNull() {
+ var planStages []RollingReleaseStage
+ diags = plan.RollingRelease.Stages.ElementsAs(ctx, &planStages, false)
+ if !diags.HasError() && i < len(planStages) {
+ // Use the duration from the plan if available
+ duration = planStages[i].Duration
+ }
+ }
+
+ // If duration is not set in plan, handle based on advancement type
+ if duration.IsNull() {
+ if plan.RollingRelease.AdvancementType.ValueString() == "automatic" {
+ if i < len(orderedStages)-1 {
+ // For non-last stages in automatic advancement, use the duration from the API
+ if stage.Duration != nil {
+ duration = types.Int64Value(int64(*stage.Duration))
+ } else {
+ // Default duration for non-last stages
+ duration = types.Int64Value(60)
+ }
+ } else {
+ // Last stage doesn't need duration
+ duration = types.Int64Null()
+ }
+ } else {
+ // For manual approval, duration is not used
+ duration = types.Int64Null()
+ }
+ }
+
+ stageObj := types.ObjectValueMust(
+ map[string]attr.Type{
+ "target_percentage": types.Int64Type,
+ "duration": types.Int64Type,
+ "require_approval": types.BoolType,
+ },
+ map[string]attr.Value{
+ "target_percentage": types.Int64Value(int64(stage.TargetPercentage)),
+ "duration": duration,
+ "require_approval": types.BoolValue(stage.RequireApproval),
+ },
+ )
+ stages[i] = stageObj
+ }
+
+ stagesList, stagesDiags := types.ListValueFrom(ctx, types.ObjectType{
+ AttrTypes: map[string]attr.Type{
+ "target_percentage": types.Int64Type,
+ "duration": types.Int64Type,
+ "require_approval": types.BoolType,
+ },
+ }, stages)
+ diags.Append(stagesDiags...)
+ if diags.HasError() {
+ return result, diags
+ }
+ result.RollingRelease.Stages = stagesList
+
+ // Log the conversion result for debugging
+ tflog.Info(ctx, "converted rolling release response", map[string]any{
+ "enabled": result.RollingRelease.Enabled.ValueBool(),
+ "advancement_type": result.RollingRelease.AdvancementType.ValueString(),
+ "stages_count": len(stages),
+ })
+
+ 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
+ }
+
+ // Convert plan to client request
+ request, diags := plan.toUpdateRollingReleaseRequest()
+ 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,
+ })
+
+ // If we're enabling, first create in disabled state then enable
+ if request.RollingRelease.Enabled {
+ // First create in disabled state
+ disabledRequest := client.UpdateRollingReleaseRequest{
+ RollingRelease: client.RollingRelease{
+ Enabled: false,
+ AdvancementType: "",
+ Stages: []client.RollingReleaseStage{},
+ },
+ ProjectID: request.ProjectID,
+ TeamID: request.TeamID,
+ }
+
+ _, err := r.client.UpdateRollingRelease(ctx, disabledRequest)
+ if err != nil {
+ resp.Diagnostics.AddError(
+ "Error creating project rolling release",
+ fmt.Sprintf("Could not create project rolling release in disabled state, unexpected error: %s",
+ err,
+ ),
+ )
+ return
+ }
+ }
+
+ out, err := r.client.UpdateRollingRelease(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{
+ "enabled": result.RollingRelease.Enabled.ValueBool(),
+ "advancement_type": result.RollingRelease.AdvancementType.ValueString(),
+ "stages": result.RollingRelease.Stages,
+ })
+
+ // 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{
+ "enabled": result.RollingRelease.Enabled.ValueBool(),
+ "advancement_type": result.RollingRelease.AdvancementType.ValueString(),
+ "stages": result.RollingRelease.Stages,
+ })
+
+ 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
+ }
+
+ var state RollingReleaseInfo
+ diags = req.State.Get(ctx, &state)
+ 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,
+ })
+
+ // If we're transitioning from enabled to disabled, first disable
+ if state.RollingRelease.Enabled.ValueBool() && !request.RollingRelease.Enabled {
+ disabledRequest := client.UpdateRollingReleaseRequest{
+ RollingRelease: client.RollingRelease{
+ Enabled: false,
+ AdvancementType: "",
+ Stages: []client.RollingReleaseStage{},
+ },
+ ProjectID: request.ProjectID,
+ TeamID: request.TeamID,
+ }
+
+ _, err := r.client.UpdateRollingRelease(ctx, disabledRequest)
+ if err != nil {
+ resp.Diagnostics.AddError(
+ "Error updating project rolling release",
+ fmt.Sprintf("Could not disable project rolling release, unexpected error: %s",
+ err,
+ ),
+ )
+ return
+ }
+ }
+
+ // If we're transitioning from disabled to enabled, first create in disabled state
+ if !state.RollingRelease.Enabled.ValueBool() && request.RollingRelease.Enabled {
+ disabledRequest := client.UpdateRollingReleaseRequest{
+ RollingRelease: client.RollingRelease{
+ Enabled: false,
+ AdvancementType: "",
+ Stages: []client.RollingReleaseStage{},
+ },
+ ProjectID: request.ProjectID,
+ TeamID: request.TeamID,
+ }
+
+ _, err := r.client.UpdateRollingRelease(ctx, disabledRequest)
+ if err != nil {
+ resp.Diagnostics.AddError(
+ "Error updating project rolling release",
+ fmt.Sprintf("Could not create project rolling release in disabled state, unexpected error: %s",
+ err,
+ ),
+ )
+ return
+ }
+ }
+
+ 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{
+ "enabled": result.RollingRelease.Enabled.ValueBool(),
+ "advancement_type": result.RollingRelease.AdvancementType.ValueString(),
+ "stages": result.RollingRelease.Stages,
+ })
+
+ 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
+ }
+
+ // Disable rolling release
+ request := client.UpdateRollingReleaseRequest{
+ RollingRelease: client.RollingRelease{
+ Enabled: false,
+ AdvancementType: "",
+ Stages: []client.RollingReleaseStage{},
+ },
+ ProjectID: state.ProjectID.ValueString(),
+ TeamID: state.TeamID.ValueString(),
+ }
+
+ _, err := r.client.UpdateRollingRelease(ctx, request)
+ 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..81dd9557
--- /dev/null
+++ b/vercel/resource_project_rolling_release_test.go
@@ -0,0 +1,240 @@
+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 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, "rolling_release.enabled", "true"),
+ resource.TestCheckResourceAttr(resourceName, "rolling_release.advancement_type", "manual-approval"),
+ resource.TestCheckResourceAttr(resourceName, "rolling_release.stages.#", "3"),
+ resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{
+ "require_approval": "true",
+ "target_percentage": "20",
+ }),
+ resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{
+ "require_approval": "true",
+ "target_percentage": "50",
+ }),
+ resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{
+ "require_approval": "false",
+ "target_percentage": "100",
+ }),
+ ),
+ },
+ // 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, "rolling_release.enabled", "true"),
+ resource.TestCheckResourceAttr(resourceName, "rolling_release.advancement_type", "manual-approval"),
+ resource.TestCheckResourceAttr(resourceName, "rolling_release.stages.#", "4"),
+ resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{
+ "require_approval": "true",
+ "target_percentage": "20",
+ }),
+ resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{
+ "require_approval": "true",
+ "target_percentage": "50",
+ }),
+ resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{
+ "require_approval": "true",
+ "target_percentage": "80",
+ }),
+ resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{
+ "require_approval": "false",
+ "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, "rolling_release.enabled", "true"),
+ resource.TestCheckResourceAttr(resourceName, "rolling_release.advancement_type", "automatic"),
+ resource.TestCheckResourceAttr(resourceName, "rolling_release.stages.#", "4"),
+ resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{
+ "require_approval": "false",
+ "target_percentage": "20",
+ "duration": "10",
+ }),
+ resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{
+ "require_approval": "false",
+ "target_percentage": "50",
+ "duration": "10",
+ }),
+ resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{
+ "require_approval": "false",
+ "target_percentage": "80",
+ "duration": "10",
+ }),
+ resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{
+ "require_approval": "false",
+ "target_percentage": "100",
+ }),
+ ),
+ },
+ // Finally disable
+ {
+ Config: cfg(testAccProjectRollingReleasesConfigDisabled(nameSuffix)),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttrSet("vercel_project.example", "id"),
+ testAccProjectRollingReleaseExists(testClient(t), resourceName, testTeam(t)),
+ resource.TestCheckResourceAttr(resourceName, "rolling_release.enabled", "false"),
+ resource.TestCheckResourceAttr(resourceName, "rolling_release.advancement_type", ""),
+ resource.TestCheckResourceAttr(resourceName, "rolling_release.stages.#", "0"),
+ ),
+ },
+ },
+ })
+}
+
+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
+ rolling_release = {
+ enabled = true
+ 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
+ rolling_release = {
+ enabled = true
+ 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
+ rolling_release = {
+ enabled = true
+ advancement_type = "automatic"
+ stages = [
+ {
+ target_percentage = 20
+ duration = 10
+ },
+ {
+ target_percentage = 50
+ duration = 10
+ },
+ {
+ target_percentage = 80
+ duration = 10
+ },
+ {
+ target_percentage = 100
+ }
+ ]
+ }
+}
+`, nameSuffix)
+}
+
+func testAccProjectRollingReleasesConfigDisabled(nameSuffix string) string {
+ return fmt.Sprintf(`
+resource "vercel_project" "example" {
+ name = "test-acc-rolling-releases-%s"
+}
+
+resource "vercel_project_rolling_release" "example" {
+ project_id = vercel_project.example.id
+ rolling_release = {
+ enabled = false
+ advancement_type = ""
+ stages = []
+ }
+}
+`, nameSuffix)
+}
diff --git a/vercel/validator_advancement_type.go b/vercel/validator_advancement_type.go
new file mode 100644
index 00000000..062846d2
--- /dev/null
+++ b/vercel/validator_advancement_type.go
@@ -0,0 +1,54 @@
+package vercel
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/hashicorp/terraform-plugin-framework/path"
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+)
+
+// Custom validator for advancement_type
+type advancementTypeValidator struct{}
+
+func (v advancementTypeValidator) Description(ctx context.Context) string {
+ return "advancement_type must be either 'automatic' or 'manual-approval'"
+}
+
+func (v advancementTypeValidator) MarkdownDescription(ctx context.Context) string {
+ return v.Description(ctx)
+}
+
+func (v advancementTypeValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
+ // Get the value of enabled from the parent object
+ var enabled types.Bool
+ diags := req.Config.GetAttribute(ctx, path.Root("rolling_release").AtName("enabled"), &enabled)
+ if diags.HasError() {
+ resp.Diagnostics.AddError(
+ "Error validating advancement_type",
+ "Could not get enabled value from configuration",
+ )
+ return
+ }
+
+ // Only validate when enabled is true
+ if enabled.ValueBool() {
+ if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
+ resp.Diagnostics.AddError(
+ "Invalid advancement_type",
+ "advancement_type is required when enabled is true",
+ )
+ return
+ }
+
+ value := req.ConfigValue.ValueString()
+ if value != "automatic" && value != "manual-approval" {
+ resp.Diagnostics.AddError(
+ "Invalid advancement_type",
+ fmt.Sprintf("advancement_type must be either 'automatic' or 'manual-approval', got: %s", value),
+ )
+ return
+ }
+ }
+}