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