From 797b9cda8673e08e9033f017706c42430eb68dd9 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Tue, 3 Jun 2025 07:09:02 -0400 Subject: [PATCH 01/70] start terraform provider --- client/project.go | 1 + client/project_rolling_release.go | 87 +++++ vercel/data_source_project_rolling_release.go | 135 +++++++ vercel/provider.go | 2 + vercel/resource_project_rolling_release.go | 349 ++++++++++++++++++ 5 files changed, 574 insertions(+) create mode 100644 client/project_rolling_release.go create mode 100644 vercel/data_source_project_rolling_release.go create mode 100644 vercel/resource_project_rolling_release.go diff --git a/client/project.go b/client/project.go index 9c84a9be..ffc68ff8 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 *ProjectRollingRelease `json:"rollingRelease,omitempty"` } type ProjectCronsResponse struct { diff --git a/client/project_rolling_release.go b/client/project_rolling_release.go new file mode 100644 index 00000000..5c9a4ae9 --- /dev/null +++ b/client/project_rolling_release.go @@ -0,0 +1,87 @@ +package client + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +type Stage struct { + TargetPercentage float64 `json:"targetPercentage,omitempty"` + Duration float64 `json:"duration,omitempty"` + RequireApproval bool `json:"requireApproval,omitempty"` +} + +// CreateRollingReleaseRequest defines the information that needs to be passed to Vercel in order to +// create a rolling release. +type RollingReleaseRequest struct { + Enabled bool `json:"enabled,omitempty"` + AdvancementType string `json:"advancementType,omitempty"` + CanaryResponseHeader string `json:"canaryResponseHeader,omitempty"` + Stages []Stage `json:"stages,omitempty"` +} + +// UpdateRollingReleaseRequest defines the information that needs to be passed to Vercel in order to +// update a rolling release. +type UpdateRollingReleaseRequest struct { + RollingRelease RollingReleaseRequest + ProjectID string + TeamID string +} + +// ProjectRollingRelease defines the rolling release configuration on the Project document. +type ProjectRollingRelease struct { + Target string `json:"target"` + Stages []Stage `json:"stages,omitempty"` + CanaryResponseHeader bool `json:"canaryResponseHeader,omitempty"` +} + +type RollingReleaseResponse struct { + TeamID string `json:"-"` +} + +func (d RollingReleaseResponse) toRollingReleaseResponse(teamID string) RollingReleaseResponse { + return RollingReleaseResponse{ + TeamID: teamID, + } +} + +// UpdateRollingRelease will update an existing rolling release to the latest information. +func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRollingReleaseRequest) (RollingReleaseResponse, error) { + url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config", c.baseURL, request.ProjectID) + + payload := string(mustMarshal(request.RollingRelease)) + + tflog.Info(ctx, "updating rolling-release", map[string]any{ + "url": url, + "payload": payload, + }) + var d RollingReleaseResponse + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "PATCH", + url: url, + body: payload, + }, &d) + return d.toRollingReleaseResponse(c.TeamID(request.TeamID)), err +} + +// GetRollingRelease returns the rolling release for a given project. +func (c *Client) GetRollingRelease(ctx context.Context, projectID, teamID string) (d RollingReleaseResponse, err error) { + project, err := c.GetProject(ctx, projectID, teamID) + if err != nil { + return RollingReleaseResponse{}, fmt.Errorf("error getting project %s: %w", projectID, err) + } + + rollingRelease := project.RollingRelease + if rollingRelease == nil { + return RollingReleaseResponse{ + TeamID: c.TeamID(teamID), + }, nil + } + + return RollingReleaseResponse{ + TeamID: c.TeamID(teamID), + }, err +} diff --git a/vercel/data_source_project_rolling_release.go b/vercel/data_source_project_rolling_release.go new file mode 100644 index 00000000..9ab9e0de --- /dev/null +++ b/vercel/data_source_project_rolling_release.go @@ -0,0 +1,135 @@ +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{} + _ datasource.DataSourceWithConfigure = &projectRollingReleaseDataSource{} +) + +func newProjectRollingReleaseDataSource() datasource.DataSource { + return &projectRollingReleaseDataSource{} +} + +type projectRollingReleaseDataSource struct { + client *client.Client +} + +func (r *projectRollingReleaseDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_project_rolling_release" +} + +func (r *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 DataSource Configure Type", + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.client = client +} + +// Schema returns the schema information for a project Rolling Release datasource. +func (r *projectRollingReleaseDataSource) Schema(_ context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: ` +Provides a Project Rolling Release datasource. + +A Project Rolling Release datasource details information about a Rolling Release on a Vercel Project. + +For more detailed information, please see the [Vercel documentation](https://vercel.com/docs/rolling-releases). +`, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "The ID of the Project for the rolling release", + Required: true, + }, + "team_id": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The ID of the Vercel team.", + }, + }, + } +} + +type ProjectRollingReleaseWithID struct { + Enabled types.Bool `tfsdk:"enabled"` + AdvancementType types.String `tfsdk:"advancement_type"` + CanaryResponseHeader types.Bool `tfsdk:"canary_response_header"` + Stages types.List `tfsdk:"stages"` + + ProjectID types.String `tfsdk:"project_id"` + TeamID types.String `tfsdk:"team_id"` + ID types.String `tfsdk:"id"` +} + +// Read will read the rolling release configuration of a Vercel project by requesting it from the Vercel API, and will update terraform +// with this information. +func (r *projectRollingReleaseDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config ProjectRollingReleaseWithID + diags := req.Config.Get(ctx, &config) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.client.GetRollingRelease(ctx, config.ProjectID.ValueString(), config.TeamID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Error reading project rolling release", + fmt.Sprintf("Could not get project rolling release %s %s, unexpected error: %s", + config.ProjectID.ValueString(), + config.TeamID.ValueString(), + err, + ), + ) + return + } + + result := convertResponseToProjectRollingRelease(out, config.ProjectID) + tflog.Info(ctx, "read project rolling release", map[string]any{ + "team_id": result.TeamID.ValueString(), + "project_id": result.ProjectID.ValueString(), + }) + + var stages []attr.Value + for _, s := range result.Stages { + stages = append(stages, types.Float64Value(s.TargetPercentage)) + } + + diags = resp.State.Set(ctx, ProjectRollingReleaseWithID{ + Enabled: types.BoolValue(result.Enabled), + AdvancementType: types.StringValue(result.AdvancementType), + CanaryResponseHeader: types.BoolValue(result.CanaryResponseHeader), + Stages: types.ListValueMust(types.StringType, stages), + ProjectID: result.ProjectID, + TeamID: result.TeamID, + ID: result.ProjectID, + }) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} 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..f793b201 --- /dev/null +++ b/vercel/resource_project_rolling_release.go @@ -0,0 +1,349 @@ +package vercel + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/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(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: ` +Provides a Project Rolling release resource. + +A Project Rolling release resource defines an Rolling release on a Vercel Project. + +For more detailed information, please see the [Vercel documentation](https://vercel.com/docs/rolling-releases). +`, + Attributes: map[string]schema.Attribute{ + "enabled": schema.BoolAttribute{ + Description: "Whether the rolling release is enabled.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{ + // RequiresReplace is used to ensure that if the value changes, the resource will be replaced. + boolplanmodifier.RequiresReplace(), + }, + }, + "advancement_type": schema.StringAttribute{ + Description: "The advancement type of the rolling release. Can be 'automatic' or 'manual-approve'.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("manual-approve"), + + PlanModifiers: []planmodifier.String{ + // RequiresReplace is used to ensure that if the value changes, the resource will be replaced. + stringplanmodifier.RequiresReplace(), + }, + }, + "canary_response_header": schema.BoolAttribute{ + Description: "Whether the canary response header is enabled. This header is used to identify canary deployments.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{ + // RequiresReplace is used to ensure that if the value changes, the resource will be replaced. + boolplanmodifier.RequiresReplace(), + }, + }, + "stages": schema.ListAttribute{ + Description: "A list of stages for the rolling release. Each stage has a target percentage and duration.", + Optional: true, + Computed: true, + ElementType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "targetPercentage": types.Float64Type, + "duration": types.Float64Type, + }, + }, + PlanModifiers: []planmodifier.List{ + // RequiresReplace is used to ensure that if the value changes, the resource will be replaced. + listplanmodifier.RequiresReplace(), + }, + }, + + "project_id": schema.StringAttribute{ + Description: "The ID of the Project for the retention policy", + Required: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "team_id": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The ID of the Vercel team.", + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplaceIfConfigured(), stringplanmodifier.UseStateForUnknown()}, + }, + }, + } +} + +type Stage struct { + TargetPercentage float64 `tfsdk:"targetPercentage,omitempty"` + Duration float64 `tfsdk:"duration,omitempty"` +} + +// ProjectRollingRelease reflects the state terraform stores internally for a project rolling release. +type ProjectRollingRelease struct { + Enabled bool `tfsdk:"enabled,omitempty"` + AdvancementType string `tfsdk:"advancementType,omitempty"` + CanaryResponseHeader bool `tfsdk:"canaryResponseHeader,omitempty"` + Stages []Stage `tfsdk:"stages,omitempty"` + ProjectID types.String `tfsdk:"project_id"` + TeamID types.String `tfsdk:"team_id"` +} + +func (e *ProjectRollingRelease) toUpdateRollingReleaseRequest() client.UpdateRollingReleaseRequest { + return client.UpdateRollingReleaseRequest{ + ProjectID: e.ProjectID.ValueString(), + TeamID: e.TeamID.ValueString(), + } +} + +// convertResponseToProjectRollingRelease is used to populate terraform state based on an API response. +// Where possible, values from the API response are used to populate state. If not possible, +// values from plan are used. +func convertResponseToProjectRollingRelease(response client.RollingReleaseResponse, projectID types.String) ProjectRollingRelease { + return ProjectRollingRelease{ + TeamID: types.StringValue(response.TeamID), + ProjectID: projectID, + } +} + +// Create will create a new rolling release config on a Vercel project. +// This is called automatically by the provider when a new resource should be created. +func (r *projectRollingReleaseResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan ProjectRollingRelease + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + _, err := r.client.GetProject(ctx, plan.ProjectID.ValueString(), plan.TeamID.ValueString()) + if client.NotFound(err) { + resp.Diagnostics.AddError( + "Error creating project rolling release", + "Could not find project, please make sure both the project_id and team_id match the project and team you wish to deploy to.", + ) + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error creating project rolling release", + "Error reading project information, unexpected error: "+err.Error(), + ) + return + } + + response, err := r.client.UpdateRollingRelease(ctx, plan.toUpdateRollingReleaseRequest()) + if err != nil { + resp.Diagnostics.AddError( + "Error creating project rolling release", + "Could not create project rolling release, unexpected error: "+err.Error(), + ) + return + } + + result := convertResponseToProjectRollingRelease(response, plan.ProjectID) + + tflog.Info(ctx, "created 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 + } +} + +// Read will read an 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 ProjectRollingRelease + 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 %s %s, unexpected error: %s", + state.ProjectID.ValueString(), + state.TeamID.ValueString(), + err, + ), + ) + return + } + + result := convertResponseToProjectRollingRelease(out, state.ProjectID) + 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 + } +} + +// Delete deletes a Vercel project rolling release. +func (r *projectRollingReleaseResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // var state ProjectRollingRelease + // diags := req.State.Get(ctx, &state) + // resp.Diagnostics.Append(diags...) + // if resp.Diagnostics.HasError() { + // return + // } + + // err := r.client.DeleteRollingRelease(ctx, state.ProjectID.ValueString(), state.TeamID.ValueString()) + // if client.NotFound(err) { + // return + // } + // if err != nil { + // resp.Diagnostics.AddError( + // "Error deleting project rolling release", + // fmt.Sprintf( + // "Could not delete project rolling release %s, unexpected error: %s", + // state.ProjectID.ValueString(), + // err, + // ), + // ) + // return + // } + + // tflog.Info(ctx, "deleted project rolling release", map[string]any{ + // "team_id": state.TeamID.ValueString(), + // "project_id": state.ProjectID.ValueString(), + // }) +} + +// Update updates the project rolling release of a Vercel project state. +func (r *projectRollingReleaseResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan ProjectRollingRelease + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + response, err := r.client.UpdateRollingRelease(ctx, plan.toUpdateRollingReleaseRequest()) + if err != nil { + resp.Diagnostics.AddError( + "Error updating project rolling release", + "Could not update project rolling release, unexpected error: "+err.Error(), + ) + return + } + + result := convertResponseToProjectRollingRelease(response, plan.ProjectID) + + tflog.Info(ctx, "updated 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 + } +} + +// 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 + } + + result := convertResponseToProjectRollingRelease(out, types.StringValue(projectID)) + 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 + } +} From 6e9343b6cf2afb12e17099e4b3d665915c5b9620 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Thu, 5 Jun 2025 13:12:09 -0400 Subject: [PATCH 02/70] wip: adding get delete --- client/project_rolling_release.go | 43 +++++++++++------ vercel/resource_project_rolling_release.go | 54 +++++++++++----------- 2 files changed, 56 insertions(+), 41 deletions(-) diff --git a/client/project_rolling_release.go b/client/project_rolling_release.go index 5c9a4ae9..0e13c81e 100644 --- a/client/project_rolling_release.go +++ b/client/project_rolling_release.go @@ -49,7 +49,7 @@ func (d RollingReleaseResponse) toRollingReleaseResponse(teamID string) RollingR // UpdateRollingRelease will update an existing rolling release to the latest information. func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRollingReleaseRequest) (RollingReleaseResponse, error) { - url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config", c.baseURL, request.ProjectID) + url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamid=%s", c.baseURL, request.ProjectID, request.TeamID) payload := string(mustMarshal(request.RollingRelease)) @@ -67,21 +67,36 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling return d.toRollingReleaseResponse(c.TeamID(request.TeamID)), err } +// DeleteRollingRelease will delete the rolling release for a given project. +func (c *Client) DeleteRollingRelease(ctx context.Context, projectID, teamID string) error { + url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, projectID, teamID) + + tflog.Info(ctx, "deleting rolling-release", map[string]any{ + "url": url, + }) + + var d RollingReleaseResponse + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "DELETE", + url: url, + }, &d) + return err +} + // GetRollingRelease returns the rolling release for a given project. func (c *Client) GetRollingRelease(ctx context.Context, projectID, teamID string) (d RollingReleaseResponse, err error) { - project, err := c.GetProject(ctx, projectID, teamID) - if err != nil { - return RollingReleaseResponse{}, fmt.Errorf("error getting project %s: %w", projectID, err) - } + url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, projectID, teamID) - rollingRelease := project.RollingRelease - if rollingRelease == nil { - return RollingReleaseResponse{ - TeamID: c.TeamID(teamID), - }, nil - } + tflog.Info(ctx, "deleting rolling-release", map[string]any{ + "url": url, + }) - return RollingReleaseResponse{ - TeamID: c.TeamID(teamID), - }, err + var d RollingReleaseResponse + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "GET", + url: url, + }, &d) + return err } diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go index f793b201..4d8319d2 100644 --- a/vercel/resource_project_rolling_release.go +++ b/vercel/resource_project_rolling_release.go @@ -250,33 +250,33 @@ func (r *projectRollingReleaseResource) Read(ctx context.Context, req resource.R // Delete deletes a Vercel project rolling release. func (r *projectRollingReleaseResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - // var state ProjectRollingRelease - // diags := req.State.Get(ctx, &state) - // resp.Diagnostics.Append(diags...) - // if resp.Diagnostics.HasError() { - // return - // } - - // err := r.client.DeleteRollingRelease(ctx, state.ProjectID.ValueString(), state.TeamID.ValueString()) - // if client.NotFound(err) { - // return - // } - // if err != nil { - // resp.Diagnostics.AddError( - // "Error deleting project rolling release", - // fmt.Sprintf( - // "Could not delete project rolling release %s, unexpected error: %s", - // state.ProjectID.ValueString(), - // err, - // ), - // ) - // return - // } - - // tflog.Info(ctx, "deleted project rolling release", map[string]any{ - // "team_id": state.TeamID.ValueString(), - // "project_id": state.ProjectID.ValueString(), - // }) + var state ProjectRollingRelease + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteRollingRelease(ctx, state.ProjectID.ValueString(), state.TeamID.ValueString()) + if client.NotFound(err) { + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error deleting project rolling release", + fmt.Sprintf( + "Could not delete project rolling release %s, unexpected error: %s", + state.ProjectID.ValueString(), + err, + ), + ) + return + } + + tflog.Info(ctx, "deleted project rolling release", map[string]any{ + "team_id": state.TeamID.ValueString(), + "project_id": state.ProjectID.ValueString(), + }) } // Update updates the project rolling release of a Vercel project state. From 9be0fbe47cb8511f90684ab57322483dc4e434d3 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Thu, 5 Jun 2025 13:57:40 -0400 Subject: [PATCH 03/70] it compiles --- client/project.go | 2 +- client/project_rolling_release.go | 85 +++++++------- vercel/data_source_project_rolling_release.go | 41 ++++--- vercel/resource_project_rolling_release.go | 108 +++++++++++------- 4 files changed, 137 insertions(+), 99 deletions(-) diff --git a/client/project.go b/client/project.go index ffc68ff8..0d48cbc2 100644 --- a/client/project.go +++ b/client/project.go @@ -210,7 +210,7 @@ type ProjectResponse struct { ResourceConfig *ResourceConfigResponse `json:"resourceConfig"` NodeVersion string `json:"nodeVersion"` Crons *ProjectCronsResponse `json:"crons"` - RollingRelease *ProjectRollingRelease `json:"rollingRelease,omitempty"` + RollingRelease *RollingRelease `json:"rollingRelease,omitempty"` } type ProjectCronsResponse struct { diff --git a/client/project_rolling_release.go b/client/project_rolling_release.go index 0e13c81e..5e2a68b7 100644 --- a/client/project_rolling_release.go +++ b/client/project_rolling_release.go @@ -7,7 +7,7 @@ import ( "github.com/hashicorp/terraform-plugin-log/tflog" ) -type Stage struct { +type RollingReleaseStage struct { TargetPercentage float64 `json:"targetPercentage,omitempty"` Duration float64 `json:"duration,omitempty"` RequireApproval bool `json:"requireApproval,omitempty"` @@ -15,40 +15,48 @@ type Stage struct { // CreateRollingReleaseRequest defines the information that needs to be passed to Vercel in order to // create a rolling release. -type RollingReleaseRequest struct { - Enabled bool `json:"enabled,omitempty"` - AdvancementType string `json:"advancementType,omitempty"` - CanaryResponseHeader string `json:"canaryResponseHeader,omitempty"` - Stages []Stage `json:"stages,omitempty"` +type RollingRelease struct { + Enabled bool `json:"enabled,omitempty"` + AdvancementType string `json:"advancementType,omitempty"` + CanaryResponseHeader bool `json:"canaryResponseHeader,omitempty"` + Stages []RollingReleaseStage `json:"stages,omitempty"` } -// UpdateRollingReleaseRequest defines the information that needs to be passed to Vercel in order to -// update a rolling release. -type UpdateRollingReleaseRequest struct { - RollingRelease RollingReleaseRequest - ProjectID string - TeamID string +type RollingReleaseInfo struct { + RollingRelease RollingRelease `json:"rollingRelease,omitempty"` + ProjectID string `json:"projectId"` + TeamID string `json:"teamId"` } -// ProjectRollingRelease defines the rolling release configuration on the Project document. -type ProjectRollingRelease struct { - Target string `json:"target"` - Stages []Stage `json:"stages,omitempty"` - CanaryResponseHeader bool `json:"canaryResponseHeader,omitempty"` -} +// GetRollingRelease returns the rolling release for a given project. +func (c *Client) GetRollingRelease(ctx context.Context, projectID, teamID string) (RollingReleaseInfo, error) { + url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, projectID, teamID) -type RollingReleaseResponse struct { - TeamID string `json:"-"` + tflog.Info(ctx, "deleting rolling-release", map[string]any{ + "url": url, + }) + + d := RollingReleaseInfo{} + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "GET", + url: url, + }, &d) + d.ProjectID = projectID + d.TeamID = teamID + return d, err } -func (d RollingReleaseResponse) toRollingReleaseResponse(teamID string) RollingReleaseResponse { - return RollingReleaseResponse{ - TeamID: teamID, - } +// 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,omitempty"` + ProjectID string `json:"projectId,omitempty"` + TeamID string `json:"teamId,omitempty"` } // UpdateRollingRelease will update an existing rolling release to the latest information. -func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRollingReleaseRequest) (RollingReleaseResponse, error) { +func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRollingReleaseRequest) (RollingReleaseInfo, error) { url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamid=%s", c.baseURL, request.ProjectID, request.TeamID) payload := string(mustMarshal(request.RollingRelease)) @@ -57,14 +65,16 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling "url": url, "payload": payload, }) - var d RollingReleaseResponse + var d RollingReleaseInfo err := c.doRequest(clientRequest{ ctx: ctx, method: "PATCH", url: url, body: payload, }, &d) - return d.toRollingReleaseResponse(c.TeamID(request.TeamID)), err + d.ProjectID = request.ProjectID + d.TeamID = request.TeamID + return d, err } // DeleteRollingRelease will delete the rolling release for a given project. @@ -75,28 +85,13 @@ func (c *Client) DeleteRollingRelease(ctx context.Context, projectID, teamID str "url": url, }) - var d RollingReleaseResponse + var d RollingReleaseInfo err := c.doRequest(clientRequest{ ctx: ctx, method: "DELETE", url: url, }, &d) - return err -} - -// GetRollingRelease returns the rolling release for a given project. -func (c *Client) GetRollingRelease(ctx context.Context, projectID, teamID string) (d RollingReleaseResponse, err error) { - url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, projectID, teamID) - - tflog.Info(ctx, "deleting rolling-release", map[string]any{ - "url": url, - }) - - var d RollingReleaseResponse - err := c.doRequest(clientRequest{ - ctx: ctx, - method: "GET", - url: url, - }, &d) + d.ProjectID = projectID + d.TeamID = teamID return err } diff --git a/vercel/data_source_project_rolling_release.go b/vercel/data_source_project_rolling_release.go index 9ab9e0de..69bd7d0d 100644 --- a/vercel/data_source_project_rolling_release.go +++ b/vercel/data_source_project_rolling_release.go @@ -108,25 +108,40 @@ func (r *projectRollingReleaseDataSource) Read(ctx context.Context, req datasour return } - result := convertResponseToProjectRollingRelease(out, config.ProjectID) + result := convertResponseToTFRollingRelease(out) tflog.Info(ctx, "read project rolling release", map[string]any{ - "team_id": result.TeamID.ValueString(), - "project_id": result.ProjectID.ValueString(), + "team_id": result.TeamID, + "project_id": result.ProjectID, }) - var stages []attr.Value - for _, s := range result.Stages { - stages = append(stages, types.Float64Value(s.TargetPercentage)) + // Convert []TFRollingReleaseStage to []attr.Value (as []types.String) + stageValues := make([]attr.Value, len(result.RollingRelease.Stages)) + + var stageAttrType = types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "duration": types.Float64Type, + "target_percentage": types.Float64Type, + "require_approval": types.BoolType, + }, + } + + for i, stage := range result.RollingRelease.Stages { + newStage := types.ObjectValueMust(stageAttrType.AttrTypes, map[string]attr.Value{ + "duration": types.Float64Value(stage.Duration), + "target_percentage": types.Float64Value(stage.TargetPercentage), + "require_approval": types.BoolValue(stage.RequireApproval), + }) + stageValues[i] = newStage } diags = resp.State.Set(ctx, ProjectRollingReleaseWithID{ - Enabled: types.BoolValue(result.Enabled), - AdvancementType: types.StringValue(result.AdvancementType), - CanaryResponseHeader: types.BoolValue(result.CanaryResponseHeader), - Stages: types.ListValueMust(types.StringType, stages), - ProjectID: result.ProjectID, - TeamID: result.TeamID, - ID: result.ProjectID, + Enabled: types.BoolValue(result.RollingRelease.Enabled), + AdvancementType: types.StringValue(result.RollingRelease.AdvancementType), + CanaryResponseHeader: types.BoolValue(result.RollingRelease.CanaryResponseHeader), + Stages: types.ListValueMust(types.StringType, stageValues), + ProjectID: types.StringValue(result.ProjectID), + TeamID: types.StringValue(result.TeamID), + ID: types.StringValue(result.ProjectID), }) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go index 4d8319d2..c9e9df2d 100644 --- a/vercel/resource_project_rolling_release.go +++ b/vercel/resource_project_rolling_release.go @@ -127,49 +127,79 @@ For more detailed information, please see the [Vercel documentation](https://ver } } -type Stage struct { +type TFRollingReleaseStage struct { TargetPercentage float64 `tfsdk:"targetPercentage,omitempty"` Duration float64 `tfsdk:"duration,omitempty"` + RequireApproval bool `tfsdk:"requireApproval,omitempty"` +} + +// TFRollingRelease reflects the state terraform stores internally for a project rolling release. +type TFRollingRelease struct { + Enabled bool `tfsdk:"enabled,omitempty"` + AdvancementType string `tfsdk:"advancementType,omitempty"` + CanaryResponseHeader bool `tfsdk:"canaryResponseHeader,omitempty"` + Stages []TFRollingReleaseStage `tfsdk:"stages,omitempty"` } // ProjectRollingRelease reflects the state terraform stores internally for a project rolling release. -type ProjectRollingRelease struct { - Enabled bool `tfsdk:"enabled,omitempty"` - AdvancementType string `tfsdk:"advancementType,omitempty"` - CanaryResponseHeader bool `tfsdk:"canaryResponseHeader,omitempty"` - Stages []Stage `tfsdk:"stages,omitempty"` - ProjectID types.String `tfsdk:"project_id"` - TeamID types.String `tfsdk:"team_id"` +type TFRollingReleaseInfo struct { + RollingRelease TFRollingRelease `tfsdk:"rollingRelease,omitempty"` + ProjectID string `tfsdk:"project_id"` + TeamID string `tfsdk:"team_id"` } -func (e *ProjectRollingRelease) toUpdateRollingReleaseRequest() client.UpdateRollingReleaseRequest { +func (e *TFRollingReleaseInfo) toUpdateRollingReleaseRequest() client.UpdateRollingReleaseRequest { return client.UpdateRollingReleaseRequest{ - ProjectID: e.ProjectID.ValueString(), - TeamID: e.TeamID.ValueString(), + RollingRelease: client.RollingRelease{ + Enabled: e.RollingRelease.Enabled, + AdvancementType: e.RollingRelease.AdvancementType, + CanaryResponseHeader: e.RollingRelease.CanaryResponseHeader, + Stages: make([]client.RollingReleaseStage, len(e.RollingRelease.Stages)), + }, + ProjectID: e.ProjectID, + TeamID: e.TeamID, } } -// convertResponseToProjectRollingRelease is used to populate terraform state based on an API response. +func convertStages(stages []client.RollingReleaseStage) []TFRollingReleaseStage { + result := make([]TFRollingReleaseStage, len(stages)) + for i, stage := range stages { + result[i] = TFRollingReleaseStage{ + TargetPercentage: stage.TargetPercentage, + Duration: stage.Duration, + RequireApproval: stage.RequireApproval, + } + } + return result +} + +// convertResponseToTFRollingRelease is used to populate terraform state based on an API response. // Where possible, values from the API response are used to populate state. If not possible, // values from plan are used. -func convertResponseToProjectRollingRelease(response client.RollingReleaseResponse, projectID types.String) ProjectRollingRelease { - return ProjectRollingRelease{ - TeamID: types.StringValue(response.TeamID), - ProjectID: projectID, +func convertResponseToTFRollingRelease(response client.RollingReleaseInfo) TFRollingReleaseInfo { + return TFRollingReleaseInfo{ + RollingRelease: TFRollingRelease{ + Enabled: response.RollingRelease.Enabled, + AdvancementType: response.RollingRelease.AdvancementType, + CanaryResponseHeader: response.RollingRelease.CanaryResponseHeader, + Stages: convertStages(response.RollingRelease.Stages), + }, + ProjectID: response.ProjectID, + TeamID: response.TeamID, } } // Create will create a new rolling release config on a Vercel project. // This is called automatically by the provider when a new resource should be created. func (r *projectRollingReleaseResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var plan ProjectRollingRelease + var plan TFRollingReleaseInfo diags := req.Plan.Get(ctx, &plan) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } - _, err := r.client.GetProject(ctx, plan.ProjectID.ValueString(), plan.TeamID.ValueString()) + _, err := r.client.GetProject(ctx, plan.ProjectID, plan.TeamID) if client.NotFound(err) { resp.Diagnostics.AddError( "Error creating project rolling release", @@ -194,11 +224,11 @@ func (r *projectRollingReleaseResource) Create(ctx context.Context, req resource return } - result := convertResponseToProjectRollingRelease(response, plan.ProjectID) + result := convertResponseToTFRollingRelease(response) tflog.Info(ctx, "created project rolling release", map[string]any{ - "team_id": result.TeamID.ValueString(), - "project_id": result.ProjectID.ValueString(), + "team_id": result.TeamID, + "project_id": result.ProjectID, }) diags = resp.State.Set(ctx, result) @@ -211,14 +241,14 @@ func (r *projectRollingReleaseResource) Create(ctx context.Context, req resource // Read will read an 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 ProjectRollingRelease + var state TFRollingReleaseInfo 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()) + out, err := r.client.GetRollingRelease(ctx, state.ProjectID, state.TeamID) if client.NotFound(err) { resp.State.RemoveResource(ctx) return @@ -227,18 +257,16 @@ func (r *projectRollingReleaseResource) Read(ctx context.Context, req resource.R resp.Diagnostics.AddError( "Error reading project rolling release", fmt.Sprintf("Could not get project rolling release %s %s, unexpected error: %s", - state.ProjectID.ValueString(), - state.TeamID.ValueString(), err, ), ) return } - result := convertResponseToProjectRollingRelease(out, state.ProjectID) + result := convertResponseToTFRollingRelease(out) tflog.Info(ctx, "read project rolling release", map[string]any{ - "team_id": result.TeamID.ValueString(), - "project_id": result.ProjectID.ValueString(), + "team_id": result.TeamID, + "project_id": result.ProjectID, }) diags = resp.State.Set(ctx, result) @@ -250,14 +278,14 @@ func (r *projectRollingReleaseResource) Read(ctx context.Context, req resource.R // Delete deletes a Vercel project rolling release. func (r *projectRollingReleaseResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var state ProjectRollingRelease + var state TFRollingReleaseInfo diags := req.State.Get(ctx, &state) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } - err := r.client.DeleteRollingRelease(ctx, state.ProjectID.ValueString(), state.TeamID.ValueString()) + err := r.client.DeleteRollingRelease(ctx, state.ProjectID, state.TeamID) if client.NotFound(err) { return } @@ -266,7 +294,7 @@ func (r *projectRollingReleaseResource) Delete(ctx context.Context, req resource "Error deleting project rolling release", fmt.Sprintf( "Could not delete project rolling release %s, unexpected error: %s", - state.ProjectID.ValueString(), + state.ProjectID, err, ), ) @@ -274,14 +302,14 @@ func (r *projectRollingReleaseResource) Delete(ctx context.Context, req resource } tflog.Info(ctx, "deleted project rolling release", map[string]any{ - "team_id": state.TeamID.ValueString(), - "project_id": state.ProjectID.ValueString(), + "team_id": state.TeamID, + "project_id": state.ProjectID, }) } // Update updates the project rolling release of a Vercel project state. func (r *projectRollingReleaseResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var plan ProjectRollingRelease + var plan TFRollingReleaseInfo diags := req.Plan.Get(ctx, &plan) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -297,11 +325,11 @@ func (r *projectRollingReleaseResource) Update(ctx context.Context, req resource return } - result := convertResponseToProjectRollingRelease(response, plan.ProjectID) + result := convertResponseToTFRollingRelease(response) tflog.Info(ctx, "updated project rolling release", map[string]any{ - "team_id": result.TeamID.ValueString(), - "project_id": result.ProjectID.ValueString(), + "team_id": result.TeamID, + "project_id": result.ProjectID, }) diags = resp.State.Set(ctx, result) @@ -335,10 +363,10 @@ func (r *projectRollingReleaseResource) ImportState(ctx context.Context, req res return } - result := convertResponseToProjectRollingRelease(out, types.StringValue(projectID)) + result := convertResponseToTFRollingRelease(out) tflog.Info(ctx, "imported project rolling release", map[string]any{ - "team_id": result.TeamID.ValueString(), - "project_id": result.ProjectID.ValueString(), + "team_id": result.TeamID, + "project_id": result.ProjectID, }) diags := resp.State.Set(ctx, result) From 757452ae8cfc8a51c159034a0875a723676ddcb0 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Thu, 5 Jun 2025 14:20:14 -0400 Subject: [PATCH 04/70] rename --- ...rce_project_rolling_release.go => resource_rolling_release.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename vercel/{resource_project_rolling_release.go => resource_rolling_release.go} (100%) diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_rolling_release.go similarity index 100% rename from vercel/resource_project_rolling_release.go rename to vercel/resource_rolling_release.go From 11ea65007304ff3662f23fb0b24e98ed8eb05766 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Thu, 5 Jun 2025 16:44:58 -0600 Subject: [PATCH 05/70] [tf] feat: support for rolling release --- client/project_rolling_release.go | 222 ++++++- client/request.go | 54 ++ vercel/data_source_project_rolling_release.go | 187 ++++-- vercel/resource_project_rolling_release.go | 606 ++++++++++++++++++ vercel/resource_rolling_release.go | 377 ----------- 5 files changed, 975 insertions(+), 471 deletions(-) create mode 100644 vercel/resource_project_rolling_release.go delete mode 100644 vercel/resource_rolling_release.go diff --git a/client/project_rolling_release.go b/client/project_rolling_release.go index 5e2a68b7..6662a70d 100644 --- a/client/project_rolling_release.go +++ b/client/project_rolling_release.go @@ -2,28 +2,89 @@ package client import ( "context" + "encoding/json" "fmt" "github.com/hashicorp/terraform-plugin-log/tflog" ) +// RollingReleaseStage represents a stage in a rolling release type RollingReleaseStage struct { - TargetPercentage float64 `json:"targetPercentage,omitempty"` - Duration float64 `json:"duration,omitempty"` - RequireApproval bool `json:"requireApproval,omitempty"` + 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 } -// CreateRollingReleaseRequest defines the information that needs to be passed to Vercel in order to -// create a rolling release. +// RollingRelease represents the rolling release configuration type RollingRelease struct { - Enabled bool `json:"enabled,omitempty"` - AdvancementType string `json:"advancementType,omitempty"` - CanaryResponseHeader bool `json:"canaryResponseHeader,omitempty"` - Stages []RollingReleaseStage `json:"stages,omitempty"` + 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 +} + +// ErrorResponse represents the error response from the Vercel API +type ErrorResponse struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +// Validate checks if the rolling release configuration is valid according to API requirements +func (r *RollingRelease) Validate() error { + if !r.Enabled { + return nil // No validation needed when disabled + } + + // Validate advancement type + if r.AdvancementType == "" { + return fmt.Errorf("advancement_type is required when enabled is true") + } + if r.AdvancementType != "automatic" && r.AdvancementType != "manual-approval" { + return fmt.Errorf("advancement_type must be 'automatic' or 'manual-approval' when enabled is true, got: %s", r.AdvancementType) + } + + // Validate stages + if len(r.Stages) == 0 { + return fmt.Errorf("stages are required when enabled is true") + } + if len(r.Stages) < 2 || len(r.Stages) > 10 { + return fmt.Errorf("must have between 2 and 10 stages when enabled is true, got: %d", len(r.Stages)) + } + + // Validate last stage is 100% + lastStage := r.Stages[len(r.Stages)-1] + if lastStage.TargetPercentage != 100 { + return fmt.Errorf("last stage must have target_percentage=100, got: %d", lastStage.TargetPercentage) + } + + // Validate stages are in ascending order and within bounds + prevPercentage := 0 + for i, stage := range r.Stages { + // Validate percentage bounds + if stage.TargetPercentage < 1 || stage.TargetPercentage > 100 { + return fmt.Errorf("stage %d: target_percentage must be between 1 and 100, got: %d", i, stage.TargetPercentage) + } + + // Validate ascending order + if stage.TargetPercentage <= prevPercentage { + return fmt.Errorf("stage %d: target_percentage must be greater than previous stage (%d), got: %d", i, prevPercentage, stage.TargetPercentage) + } + prevPercentage = stage.TargetPercentage + + // Validate duration for automatic advancement + if r.AdvancementType == "automatic" { + if stage.Duration == nil || *stage.Duration < 1 || *stage.Duration > 10000 { + return fmt.Errorf("stage %d: duration must be between 1 and 10000 minutes for automatic advancement, got: %d", i, *stage.Duration) + } + } + } + + return nil } type RollingReleaseInfo struct { - RollingRelease RollingRelease `json:"rollingRelease,omitempty"` + RollingRelease RollingRelease `json:"rollingRelease"` ProjectID string `json:"projectId"` TeamID string `json:"teamId"` } @@ -32,8 +93,11 @@ type RollingReleaseInfo struct { func (c *Client) GetRollingRelease(ctx context.Context, projectID, teamID string) (RollingReleaseInfo, error) { url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, projectID, teamID) - tflog.Info(ctx, "deleting rolling-release", map[string]any{ + tflog.Debug(ctx, "getting rolling-release configuration", map[string]any{ "url": url, + "method": "GET", + "project_id": projectID, + "team_id": teamID, }) d := RollingReleaseInfo{} @@ -50,39 +114,149 @@ func (c *Client) GetRollingRelease(ctx context.Context, projectID, teamID string // 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,omitempty"` - ProjectID string `json:"projectId,omitempty"` - TeamID string `json:"teamId,omitempty"` + 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) { - url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamid=%s", c.baseURL, request.ProjectID, request.TeamID) + // Validate the request + if err := request.RollingRelease.Validate(); err != nil { + return RollingReleaseInfo{}, fmt.Errorf("invalid rolling release configuration: %w", err) + } + + url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, request.ProjectID, request.TeamID) - payload := string(mustMarshal(request.RollingRelease)) + // Process stages to ensure final stage only has targetPercentage + stages := make([]map[string]any, len(request.RollingRelease.Stages)) + for i, stage := range request.RollingRelease.Stages { + if i == len(request.RollingRelease.Stages)-1 { + // Final stage should only have targetPercentage + stages[i] = map[string]any{ + "targetPercentage": stage.TargetPercentage, + } + } else { + // Other stages can have all properties + stageMap := map[string]any{ + "targetPercentage": stage.TargetPercentage, + "requireApproval": stage.RequireApproval, + } + // Only include duration if it's set + if stage.Duration != nil { + stageMap["duration"] = *stage.Duration + } + stages[i] = stageMap + } + } - tflog.Info(ctx, "updating rolling-release", map[string]any{ - "url": url, + // Send just the rolling release configuration, not the whole request + payload := string(mustMarshal(map[string]any{ + "enabled": request.RollingRelease.Enabled, + "advancementType": request.RollingRelease.AdvancementType, + "stages": stages, + })) + + tflog.Debug(ctx, "updating rolling-release configuration", map[string]any{ + "url": url, + "method": "PATCH", + "project_id": request.ProjectID, + "team_id": request.TeamID, "payload": payload, + "base_url": c.baseURL, + "enabled": request.RollingRelease.Enabled, + "advancement_type": request.RollingRelease.AdvancementType, + "stages_count": len(request.RollingRelease.Stages), }) + + // Log each stage for debugging + for i, stage := range stages { + tflog.Debug(ctx, fmt.Sprintf("stage %d configuration", i), map[string]any{ + "stage": stage, + }) + } + var d RollingReleaseInfo - err := c.doRequest(clientRequest{ + resp, err := c.doRequestWithResponse(clientRequest{ ctx: ctx, method: "PATCH", url: url, body: payload, - }, &d) - d.ProjectID = request.ProjectID - d.TeamID = request.TeamID - return d, err + }) + + // Always log the raw response for debugging + tflog.Debug(ctx, "received raw response", map[string]any{ + "response": resp, + }) + + if err != nil { + // Try to parse error response + var errResp ErrorResponse + if resp != "" && json.Unmarshal([]byte(resp), &errResp) == nil { + tflog.Error(ctx, "error updating rolling-release", map[string]any{ + "error_code": errResp.Error.Code, + "error_message": errResp.Error.Message, + "url": url, + "payload": payload, + "response": resp, + }) + return d, fmt.Errorf("failed to update rolling release: %s - %s", errResp.Error.Code, errResp.Error.Message) + } + + tflog.Error(ctx, "error updating rolling-release", map[string]any{ + "error": err.Error(), + "url": url, + "payload": payload, + "response": resp, + }) + return d, fmt.Errorf("failed to update rolling release: %w", err) + } + + // Return the request state since we know it's valid + result := RollingReleaseInfo{ + ProjectID: request.ProjectID, + TeamID: request.TeamID, + RollingRelease: RollingRelease{ + Enabled: request.RollingRelease.Enabled, + AdvancementType: request.RollingRelease.AdvancementType, + Stages: make([]RollingReleaseStage, len(request.RollingRelease.Stages)), + }, + } + + // Copy stages, preserving the duration and requireApproval for non-final stages + for i, stage := range request.RollingRelease.Stages { + if i == len(request.RollingRelease.Stages)-1 { + // For the final stage, only include targetPercentage + result.RollingRelease.Stages[i] = RollingReleaseStage{ + TargetPercentage: stage.TargetPercentage, + // Do not include Duration or RequireApproval for final stage + } + } else { + // For other stages, include all properties + result.RollingRelease.Stages[i] = stage + } + } + + tflog.Debug(ctx, "returning rolling release configuration", map[string]any{ + "project_id": result.ProjectID, + "team_id": result.TeamID, + "enabled": result.RollingRelease.Enabled, + "advancement_type": result.RollingRelease.AdvancementType, + "stages": result.RollingRelease.Stages, + }) + + return result, nil } // DeleteRollingRelease will delete the rolling release for a given project. func (c *Client) DeleteRollingRelease(ctx context.Context, projectID, teamID string) error { url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, projectID, teamID) - tflog.Info(ctx, "deleting rolling-release", map[string]any{ + tflog.Debug(ctx, "deleting rolling-release configuration", map[string]any{ "url": url, + "method": "DELETE", + "project_id": projectID, + "team_id": teamID, }) var d RollingReleaseInfo diff --git a/client/request.go b/client/request.go index 75a58720..798cb78b 100644 --- a/client/request.go +++ b/client/request.go @@ -166,3 +166,57 @@ func (c *Client) _doRequest(req *http.Request, v any, errorOnNoContent bool) err return nil } + +// doRequestWithResponse is similar to doRequest but returns the raw response body as a string +func (c *Client) doRequestWithResponse(req clientRequest) (string, error) { + r, err := req.toHTTPRequest() + if err != nil { + return "", err + } + + r.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.token)) + resp, err := c.http().Do(r) + if err != nil { + return "", fmt.Errorf("error doing http request: %w", err) + } + + defer resp.Body.Close() + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("error reading response body: %w", err) + } + + if resp.StatusCode >= 300 { + var errorResponse APIError + if string(responseBody) == "" { + errorResponse.StatusCode = resp.StatusCode + return string(responseBody), errorResponse + } + err = json.Unmarshal(responseBody, &struct { + Error *APIError `json:"error"` + }{ + Error: &errorResponse, + }) + if errorResponse.Code == "" && errorResponse.Message == "" { + return string(responseBody), fmt.Errorf("error performing API request: %d %s", resp.StatusCode, string(responseBody)) + } + if err != nil { + return string(responseBody), fmt.Errorf("error unmarshaling response for status code %d: %w: %s", resp.StatusCode, err, string(responseBody)) + } + errorResponse.StatusCode = resp.StatusCode + errorResponse.RawMessage = responseBody + errorResponse.retryAfter = 1000 // set a sensible default for retrying. This is in milliseconds. + if resp.StatusCode == 429 { + retryAfterRaw := resp.Header.Get("Retry-After") + if retryAfterRaw != "" { + retryAfter, err := strconv.Atoi(retryAfterRaw) + if err == nil && retryAfter > 0 { + errorResponse.retryAfter = retryAfter + } + } + } + return string(responseBody), errorResponse + } + + return string(responseBody), nil +} diff --git a/vercel/data_source_project_rolling_release.go b/vercel/data_source_project_rolling_release.go index 69bd7d0d..93e99066 100644 --- a/vercel/data_source_project_rolling_release.go +++ b/vercel/data_source_project_rolling_release.go @@ -4,7 +4,6 @@ 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" @@ -13,8 +12,7 @@ import ( ) var ( - _ datasource.DataSource = &projectRollingReleaseDataSource{} - _ datasource.DataSourceWithConfigure = &projectRollingReleaseDataSource{} + _ datasource.DataSource = &projectRollingReleaseDataSource{} ) func newProjectRollingReleaseDataSource() datasource.DataSource { @@ -25,11 +23,11 @@ type projectRollingReleaseDataSource struct { client *client.Client } -func (r *projectRollingReleaseDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { +func (d *projectRollingReleaseDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_project_rolling_release" } -func (r *projectRollingReleaseDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { +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 @@ -38,111 +36,160 @@ func (r *projectRollingReleaseDataSource) Configure(ctx context.Context, req dat client, ok := req.ProviderData.(*client.Client) if !ok { resp.Diagnostics.AddError( - "Unexpected DataSource Configure Type", + "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 + d.client = client } -// Schema returns the schema information for a project Rolling Release datasource. -func (r *projectRollingReleaseDataSource) Schema(_ context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { +func (d *projectRollingReleaseDataSource) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { resp.Schema = schema.Schema{ - Description: ` -Provides a Project Rolling Release datasource. - -A Project Rolling Release datasource details information about a Rolling Release on a Vercel Project. - -For more detailed information, please see the [Vercel documentation](https://vercel.com/docs/rolling-releases). -`, + MarkdownDescription: "Data source for a Vercel project rolling release configuration.", Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - }, "project_id": schema.StringAttribute{ - Description: "The ID of the Project for the rolling release", - Required: true, + MarkdownDescription: "The ID of the project.", + Required: true, }, "team_id": schema.StringAttribute{ - Optional: true, - Computed: true, - Description: "The ID of the Vercel team.", + MarkdownDescription: "The ID of the team the project exists in.", + Required: true, + }, + "rolling_release": schema.SingleNestedAttribute{ + MarkdownDescription: "The rolling release configuration.", + Computed: 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.", + Computed: true, + }, + "require_approval": schema.BoolAttribute{ + MarkdownDescription: "Whether approval is required before advancing to the next stage.", + Computed: true, + }, + }, + }, + }, + }, }, }, } } -type ProjectRollingReleaseWithID struct { - Enabled types.Bool `tfsdk:"enabled"` - AdvancementType types.String `tfsdk:"advancement_type"` - CanaryResponseHeader types.Bool `tfsdk:"canary_response_header"` - Stages types.List `tfsdk:"stages"` +type TFRollingReleaseStageDataSource struct { + TargetPercentage types.Int64 `tfsdk:"target_percentage"` + Duration types.Int64 `tfsdk:"duration"` + RequireApproval types.Bool `tfsdk:"require_approval"` +} - ProjectID types.String `tfsdk:"project_id"` - TeamID types.String `tfsdk:"team_id"` - ID types.String `tfsdk:"id"` +type TFRollingReleaseDataSource struct { + Enabled types.Bool `tfsdk:"enabled"` + AdvancementType types.String `tfsdk:"advancement_type"` + Stages []TFRollingReleaseStageDataSource `tfsdk:"stages"` } -// Read will read the rolling release configuration of a Vercel project by requesting it from the Vercel API, and will update terraform -// with this information. -func (r *projectRollingReleaseDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { - var config ProjectRollingReleaseWithID +type TFRollingReleaseInfoDataSource struct { + RollingRelease TFRollingReleaseDataSource `tfsdk:"rolling_release"` + ProjectID types.String `tfsdk:"project_id"` + TeamID types.String `tfsdk:"team_id"` +} + +func convertStagesDataSource(stages []client.RollingReleaseStage) []TFRollingReleaseStageDataSource { + if len(stages) == 0 { + return make([]TFRollingReleaseStageDataSource, 0) + } + + result := make([]TFRollingReleaseStageDataSource, len(stages)) + for i, stage := range stages { + var duration types.Int64 + if stage.Duration != nil { + duration = types.Int64Value(int64(*stage.Duration)) + } else { + duration = types.Int64Null() + } + + result[i] = TFRollingReleaseStageDataSource{ + TargetPercentage: types.Int64Value(int64(stage.TargetPercentage)), + Duration: duration, + RequireApproval: types.BoolValue(stage.RequireApproval), + } + } + return result +} + +func convertResponseToTFRollingReleaseDataSource(response client.RollingReleaseInfo) TFRollingReleaseInfoDataSource { + result := TFRollingReleaseInfoDataSource{ + RollingRelease: TFRollingReleaseDataSource{ + Enabled: types.BoolValue(response.RollingRelease.Enabled), + AdvancementType: types.StringNull(), + Stages: make([]TFRollingReleaseStageDataSource, 0), + }, + ProjectID: types.StringValue(response.ProjectID), + TeamID: types.StringValue(response.TeamID), + } + + if response.RollingRelease.Enabled { + result.RollingRelease.AdvancementType = types.StringValue(response.RollingRelease.AdvancementType) + result.RollingRelease.Stages = convertStagesDataSource(response.RollingRelease.Stages) + } + + return result +} + +func (d *projectRollingReleaseDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config TFRollingReleaseInfoDataSource diags := req.Config.Get(ctx, &config) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } - out, err := r.client.GetRollingRelease(ctx, config.ProjectID.ValueString(), config.TeamID.ValueString()) + 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.ProjectID.ValueString(), config.TeamID.ValueString(), + config.ProjectID.ValueString(), err, ), ) return } - result := convertResponseToTFRollingRelease(out) + result := convertResponseToTFRollingReleaseDataSource(out) tflog.Info(ctx, "read project rolling release", map[string]any{ - "team_id": result.TeamID, - "project_id": result.ProjectID, + "team_id": result.TeamID.ValueString(), + "project_id": result.ProjectID.ValueString(), }) - // Convert []TFRollingReleaseStage to []attr.Value (as []types.String) - stageValues := make([]attr.Value, len(result.RollingRelease.Stages)) - - var stageAttrType = types.ObjectType{ - AttrTypes: map[string]attr.Type{ - "duration": types.Float64Type, - "target_percentage": types.Float64Type, - "require_approval": types.BoolType, - }, - } - - for i, stage := range result.RollingRelease.Stages { - newStage := types.ObjectValueMust(stageAttrType.AttrTypes, map[string]attr.Value{ - "duration": types.Float64Value(stage.Duration), - "target_percentage": types.Float64Value(stage.TargetPercentage), - "require_approval": types.BoolValue(stage.RequireApproval), - }) - stageValues[i] = newStage - } - - diags = resp.State.Set(ctx, ProjectRollingReleaseWithID{ - Enabled: types.BoolValue(result.RollingRelease.Enabled), - AdvancementType: types.StringValue(result.RollingRelease.AdvancementType), - CanaryResponseHeader: types.BoolValue(result.RollingRelease.CanaryResponseHeader), - Stages: types.ListValueMust(types.StringType, stageValues), - ProjectID: types.StringValue(result.ProjectID), - TeamID: types.StringValue(result.TeamID), - ID: types.StringValue(result.ProjectID), - }) + diags = resp.State.Set(ctx, result) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go new file mode 100644 index 00000000..f427e184 --- /dev/null +++ b/vercel/resource_project_rolling_release.go @@ -0,0 +1,606 @@ +package vercel + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/vercel/terraform-provider-vercel/v3/client" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/attr" +) + +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 +} + +// 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) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + 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), + ) + } +} + +// 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{ + MarkdownDescription: "The ID of the team the project exists in.", + Required: true, + }, + "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, + }, + "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, + Computed: true, + }, + "require_approval": schema.BoolAttribute{ + MarkdownDescription: "Whether approval is required before advancing to the next stage.", + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + }, + } +} + +type TFRollingReleaseStage struct { + TargetPercentage types.Int64 `tfsdk:"target_percentage"` + Duration types.Int64 `tfsdk:"duration"` + RequireApproval types.Bool `tfsdk:"require_approval"` +} + +// TFRollingRelease reflects the state terraform stores internally for a project rolling release. +type TFRollingRelease 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 TFRollingReleaseInfo struct { + RollingRelease TFRollingRelease `tfsdk:"rolling_release"` + ProjectID types.String `tfsdk:"project_id"` + TeamID types.String `tfsdk:"team_id"` +} + +type RollingReleaseStage struct { + TargetPercentage int `json:"targetPercentage"` + Duration *int `json:"duration,omitempty"` + RequireApproval bool `json:"requireApproval"` +} + +type RollingRelease struct { + Enabled bool `json:"enabled"` + AdvancementType string `json:"advancementType"` + Stages []RollingReleaseStage `json:"stages"` +} + +type UpdateRollingReleaseRequest struct { + RollingRelease RollingRelease `json:"rollingRelease"` + ProjectID string `json:"-"` + TeamID string `json:"-"` +} + +func (e *TFRollingReleaseInfo) toUpdateRollingReleaseRequest() (client.UpdateRollingReleaseRequest, diag.Diagnostics) { + var stages []client.RollingReleaseStage + var advancementType string + var diags diag.Diagnostics + + if e.RollingRelease.Enabled.ValueBool() { + if !e.RollingRelease.AdvancementType.IsNull() { + advancementType = e.RollingRelease.AdvancementType.ValueString() + } else { + advancementType = "manual-approval" // Default to manual-approval if not specified + } + + // Convert stages from types.List to []client.RollingReleaseStage + var tfStages []TFRollingReleaseStage + if !e.RollingRelease.Stages.IsNull() && !e.RollingRelease.Stages.IsUnknown() { + 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 { + // For automatic advancement, set a default duration if not provided + if advancementType == "automatic" { + var duration int = 60 // Default duration in minutes + if !stage.Duration.IsNull() { + duration = int(stage.Duration.ValueInt64()) + } + stages[i] = client.RollingReleaseStage{ + TargetPercentage: int(stage.TargetPercentage.ValueInt64()), + Duration: &duration, + RequireApproval: stage.RequireApproval.ValueBool(), + } + } else { + // For manual approval, omit duration field completely + stages[i] = client.RollingReleaseStage{ + TargetPercentage: int(stage.TargetPercentage.ValueInt64()), + RequireApproval: stage.RequireApproval.ValueBool(), + } + } + } + } + } else { + // When disabled, don't send any stages to the API + stages = []client.RollingReleaseStage{} + } + + 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 convertStages(stages []client.RollingReleaseStage, advancementType string, planStages []TFRollingReleaseStage, enabled bool, ctx context.Context) (types.List, diag.Diagnostics) { + // If disabled, always return plan stages to preserve state + if !enabled && len(planStages) > 0 { + elements := make([]attr.Value, len(planStages)) + for i, stage := range planStages { + // For disabled state, ensure duration is known + var duration types.Int64 + if stage.Duration.IsUnknown() { + duration = types.Int64Null() + } else { + duration = stage.Duration + } + + elements[i] = types.ObjectValueMust( + map[string]attr.Type{ + "target_percentage": types.Int64Type, + "duration": types.Int64Type, + "require_approval": types.BoolType, + }, + map[string]attr.Value{ + "target_percentage": stage.TargetPercentage, + "duration": duration, + "require_approval": stage.RequireApproval, + }, + ) + } + return types.ListValueFrom(ctx, types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "target_percentage": types.Int64Type, + "duration": types.Int64Type, + "require_approval": types.BoolType, + }, + }, elements) + } + + // If no stages from API and no plan stages, return empty list + if len(stages) == 0 { + return types.ListValueFrom(ctx, types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "target_percentage": types.Int64Type, + "duration": types.Int64Type, + "require_approval": types.BoolType, + }, + }, []attr.Value{}) + } + + elements := make([]attr.Value, len(stages)) + for i, stage := range stages { + targetPercentage := types.Int64Value(int64(stage.TargetPercentage)) + requireApproval := types.BoolValue(stage.RequireApproval) + var duration types.Int64 + + // If we have plan stages, preserve the values but ensure they're known + if i < len(planStages) { + targetPercentage = planStages[i].TargetPercentage + requireApproval = planStages[i].RequireApproval + + // Handle duration based on advancement type + if advancementType == "automatic" { + if planStages[i].Duration.IsUnknown() { + // For unknown values, use API value or default + if stage.Duration != nil { + duration = types.Int64Value(int64(*stage.Duration)) + } else { + duration = types.Int64Value(60) // Default duration in minutes + } + } else { + duration = planStages[i].Duration + } + } else { + duration = types.Int64Null() // Manual approval doesn't use duration + } + } else { + // Only set duration for automatic advancement + if advancementType == "automatic" { + if stage.Duration != nil { + duration = types.Int64Value(int64(*stage.Duration)) + } else { + duration = types.Int64Value(60) // Default duration in minutes + } + } else { + duration = types.Int64Null() + } + } + + elements[i] = types.ObjectValueMust( + map[string]attr.Type{ + "target_percentage": types.Int64Type, + "duration": types.Int64Type, + "require_approval": types.BoolType, + }, + map[string]attr.Value{ + "target_percentage": targetPercentage, + "duration": duration, + "require_approval": requireApproval, + }, + ) + } + + return types.ListValueFrom(ctx, types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "target_percentage": types.Int64Type, + "duration": types.Int64Type, + "require_approval": types.BoolType, + }, + }, elements) +} + +func convertResponseToTFRollingRelease(response client.RollingReleaseInfo, plan *TFRollingReleaseInfo, ctx context.Context) (TFRollingReleaseInfo, diag.Diagnostics) { + var diags diag.Diagnostics + + result := TFRollingReleaseInfo{ + RollingRelease: TFRollingRelease{ + Enabled: types.BoolValue(response.RollingRelease.Enabled), + }, + ProjectID: types.StringValue(response.ProjectID), + TeamID: types.StringValue(response.TeamID), + } + + // Get plan stages if available + var planStages []TFRollingReleaseStage + if plan != nil && !plan.RollingRelease.Stages.IsNull() && !plan.RollingRelease.Stages.IsUnknown() { + diags.Append(plan.RollingRelease.Stages.ElementsAs(ctx, &planStages, false)...) + if diags.HasError() { + return result, diags + } + } + + if response.RollingRelease.Enabled { + result.RollingRelease.AdvancementType = types.StringValue(response.RollingRelease.AdvancementType) + } else { + result.RollingRelease.AdvancementType = types.StringNull() + } + + // Convert stages, passing enabled state to ensure proper preservation + stages, stagesDiags := convertStages( + response.RollingRelease.Stages, + response.RollingRelease.AdvancementType, + planStages, + response.RollingRelease.Enabled, + ctx, + ) + diags.Append(stagesDiags...) + if diags.HasError() { + return result, diags + } + result.RollingRelease.Stages = stages + + return result, diags +} + +// Create will create a new rolling release config on a Vercel project. +// This is called automatically by the provider when a new resource should be created. +func (r *projectRollingReleaseResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + tflog.Debug(ctx, "Starting rolling release creation") + + var plan TFRollingReleaseInfo + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Debug(ctx, "Got plan from request", map[string]any{ + "project_id": plan.ProjectID.ValueString(), + "team_id": plan.TeamID.ValueString(), + "enabled": plan.RollingRelease.Enabled.ValueBool(), + "advancement_type": plan.RollingRelease.AdvancementType.ValueString(), + "stages": plan.RollingRelease.Stages, + }) + + _, err := r.client.GetProject(ctx, plan.ProjectID.ValueString(), plan.TeamID.ValueString()) + if client.NotFound(err) { + resp.Diagnostics.AddError( + "Error creating project rolling release", + "Could not find project, please make sure both the project_id and team_id match the project and team you wish to deploy to.", + ) + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error creating project rolling release", + "Error reading project information, unexpected error: "+err.Error(), + ) + return + } + + tflog.Debug(ctx, "Project exists, creating rolling release") + + updateRequest, diags := plan.toUpdateRollingReleaseRequest() + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + response, err := r.client.UpdateRollingRelease(ctx, updateRequest) + if err != nil { + resp.Diagnostics.AddError( + "Error creating project rolling release", + "Could not create project rolling release, unexpected error: "+err.Error(), + ) + return + } + + result, diags := convertResponseToTFRollingRelease(response, &plan, ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Log the values for debugging + tflog.Debug(ctx, "created project rolling release", map[string]any{ + "team_id": result.TeamID.ValueString(), + "project_id": result.ProjectID.ValueString(), + "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 + } +} + +// Read will read an 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 TFRollingReleaseInfo + 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 %s %s, unexpected error: %s", + err, + ), + ) + return + } + + result, diags := convertResponseToTFRollingRelease(out, nil, ctx) + 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 + } +} + +// Delete deletes a Vercel project rolling release. +func (r *projectRollingReleaseResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state TFRollingReleaseInfo + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteRollingRelease(ctx, state.ProjectID.ValueString(), state.TeamID.ValueString()) + if client.NotFound(err) { + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error deleting project rolling release", + fmt.Sprintf( + "Could not delete project rolling release %s, unexpected error: %s", + state.ProjectID.ValueString(), + err, + ), + ) + return + } + + tflog.Info(ctx, "deleted project rolling release", map[string]any{ + "team_id": state.TeamID.ValueString(), + "project_id": state.ProjectID.ValueString(), + }) +} + +// Update updates the project rolling release of a Vercel project state. +func (r *projectRollingReleaseResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan TFRollingReleaseInfo + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + updateRequest, diags := plan.toUpdateRollingReleaseRequest() + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + response, err := r.client.UpdateRollingRelease(ctx, updateRequest) + if err != nil { + resp.Diagnostics.AddError( + "Error updating project rolling release", + "Could not update project rolling release, unexpected error: "+err.Error(), + ) + return + } + + result, diags := convertResponseToTFRollingRelease(response, &plan, ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Log the values for debugging + tflog.Debug(ctx, "updated project rolling release", map[string]any{ + "team_id": result.TeamID.ValueString(), + "project_id": result.ProjectID.ValueString(), + "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 + } +} + +// 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 + } + + result, diags := convertResponseToTFRollingRelease(out, nil, ctx) + 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_rolling_release.go b/vercel/resource_rolling_release.go deleted file mode 100644 index c9e9df2d..00000000 --- a/vercel/resource_rolling_release.go +++ /dev/null @@ -1,377 +0,0 @@ -package vercel - -import ( - "context" - "fmt" - - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/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(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = schema.Schema{ - Description: ` -Provides a Project Rolling release resource. - -A Project Rolling release resource defines an Rolling release on a Vercel Project. - -For more detailed information, please see the [Vercel documentation](https://vercel.com/docs/rolling-releases). -`, - Attributes: map[string]schema.Attribute{ - "enabled": schema.BoolAttribute{ - Description: "Whether the rolling release is enabled.", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), - PlanModifiers: []planmodifier.Bool{ - // RequiresReplace is used to ensure that if the value changes, the resource will be replaced. - boolplanmodifier.RequiresReplace(), - }, - }, - "advancement_type": schema.StringAttribute{ - Description: "The advancement type of the rolling release. Can be 'automatic' or 'manual-approve'.", - Optional: true, - Computed: true, - Default: stringdefault.StaticString("manual-approve"), - - PlanModifiers: []planmodifier.String{ - // RequiresReplace is used to ensure that if the value changes, the resource will be replaced. - stringplanmodifier.RequiresReplace(), - }, - }, - "canary_response_header": schema.BoolAttribute{ - Description: "Whether the canary response header is enabled. This header is used to identify canary deployments.", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), - PlanModifiers: []planmodifier.Bool{ - // RequiresReplace is used to ensure that if the value changes, the resource will be replaced. - boolplanmodifier.RequiresReplace(), - }, - }, - "stages": schema.ListAttribute{ - Description: "A list of stages for the rolling release. Each stage has a target percentage and duration.", - Optional: true, - Computed: true, - ElementType: types.ObjectType{ - AttrTypes: map[string]attr.Type{ - "targetPercentage": types.Float64Type, - "duration": types.Float64Type, - }, - }, - PlanModifiers: []planmodifier.List{ - // RequiresReplace is used to ensure that if the value changes, the resource will be replaced. - listplanmodifier.RequiresReplace(), - }, - }, - - "project_id": schema.StringAttribute{ - Description: "The ID of the Project for the retention policy", - Required: true, - PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, - }, - "team_id": schema.StringAttribute{ - Optional: true, - Computed: true, - Description: "The ID of the Vercel team.", - PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplaceIfConfigured(), stringplanmodifier.UseStateForUnknown()}, - }, - }, - } -} - -type TFRollingReleaseStage struct { - TargetPercentage float64 `tfsdk:"targetPercentage,omitempty"` - Duration float64 `tfsdk:"duration,omitempty"` - RequireApproval bool `tfsdk:"requireApproval,omitempty"` -} - -// TFRollingRelease reflects the state terraform stores internally for a project rolling release. -type TFRollingRelease struct { - Enabled bool `tfsdk:"enabled,omitempty"` - AdvancementType string `tfsdk:"advancementType,omitempty"` - CanaryResponseHeader bool `tfsdk:"canaryResponseHeader,omitempty"` - Stages []TFRollingReleaseStage `tfsdk:"stages,omitempty"` -} - -// ProjectRollingRelease reflects the state terraform stores internally for a project rolling release. -type TFRollingReleaseInfo struct { - RollingRelease TFRollingRelease `tfsdk:"rollingRelease,omitempty"` - ProjectID string `tfsdk:"project_id"` - TeamID string `tfsdk:"team_id"` -} - -func (e *TFRollingReleaseInfo) toUpdateRollingReleaseRequest() client.UpdateRollingReleaseRequest { - return client.UpdateRollingReleaseRequest{ - RollingRelease: client.RollingRelease{ - Enabled: e.RollingRelease.Enabled, - AdvancementType: e.RollingRelease.AdvancementType, - CanaryResponseHeader: e.RollingRelease.CanaryResponseHeader, - Stages: make([]client.RollingReleaseStage, len(e.RollingRelease.Stages)), - }, - ProjectID: e.ProjectID, - TeamID: e.TeamID, - } -} - -func convertStages(stages []client.RollingReleaseStage) []TFRollingReleaseStage { - result := make([]TFRollingReleaseStage, len(stages)) - for i, stage := range stages { - result[i] = TFRollingReleaseStage{ - TargetPercentage: stage.TargetPercentage, - Duration: stage.Duration, - RequireApproval: stage.RequireApproval, - } - } - return result -} - -// convertResponseToTFRollingRelease is used to populate terraform state based on an API response. -// Where possible, values from the API response are used to populate state. If not possible, -// values from plan are used. -func convertResponseToTFRollingRelease(response client.RollingReleaseInfo) TFRollingReleaseInfo { - return TFRollingReleaseInfo{ - RollingRelease: TFRollingRelease{ - Enabled: response.RollingRelease.Enabled, - AdvancementType: response.RollingRelease.AdvancementType, - CanaryResponseHeader: response.RollingRelease.CanaryResponseHeader, - Stages: convertStages(response.RollingRelease.Stages), - }, - ProjectID: response.ProjectID, - TeamID: response.TeamID, - } -} - -// Create will create a new rolling release config on a Vercel project. -// This is called automatically by the provider when a new resource should be created. -func (r *projectRollingReleaseResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var plan TFRollingReleaseInfo - diags := req.Plan.Get(ctx, &plan) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - _, err := r.client.GetProject(ctx, plan.ProjectID, plan.TeamID) - if client.NotFound(err) { - resp.Diagnostics.AddError( - "Error creating project rolling release", - "Could not find project, please make sure both the project_id and team_id match the project and team you wish to deploy to.", - ) - return - } - if err != nil { - resp.Diagnostics.AddError( - "Error creating project rolling release", - "Error reading project information, unexpected error: "+err.Error(), - ) - return - } - - response, err := r.client.UpdateRollingRelease(ctx, plan.toUpdateRollingReleaseRequest()) - if err != nil { - resp.Diagnostics.AddError( - "Error creating project rolling release", - "Could not create project rolling release, unexpected error: "+err.Error(), - ) - return - } - - result := convertResponseToTFRollingRelease(response) - - tflog.Info(ctx, "created project rolling release", map[string]any{ - "team_id": result.TeamID, - "project_id": result.ProjectID, - }) - - diags = resp.State.Set(ctx, result) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } -} - -// Read will read an 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 TFRollingReleaseInfo - diags := req.State.Get(ctx, &state) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - out, err := r.client.GetRollingRelease(ctx, state.ProjectID, state.TeamID) - 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 %s %s, unexpected error: %s", - err, - ), - ) - return - } - - result := convertResponseToTFRollingRelease(out) - tflog.Info(ctx, "read project rolling release", map[string]any{ - "team_id": result.TeamID, - "project_id": result.ProjectID, - }) - - diags = resp.State.Set(ctx, result) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } -} - -// Delete deletes a Vercel project rolling release. -func (r *projectRollingReleaseResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var state TFRollingReleaseInfo - diags := req.State.Get(ctx, &state) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - err := r.client.DeleteRollingRelease(ctx, state.ProjectID, state.TeamID) - if client.NotFound(err) { - return - } - if err != nil { - resp.Diagnostics.AddError( - "Error deleting project rolling release", - fmt.Sprintf( - "Could not delete project rolling release %s, unexpected error: %s", - state.ProjectID, - err, - ), - ) - return - } - - tflog.Info(ctx, "deleted project rolling release", map[string]any{ - "team_id": state.TeamID, - "project_id": state.ProjectID, - }) -} - -// Update updates the project rolling release of a Vercel project state. -func (r *projectRollingReleaseResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var plan TFRollingReleaseInfo - diags := req.Plan.Get(ctx, &plan) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - response, err := r.client.UpdateRollingRelease(ctx, plan.toUpdateRollingReleaseRequest()) - if err != nil { - resp.Diagnostics.AddError( - "Error updating project rolling release", - "Could not update project rolling release, unexpected error: "+err.Error(), - ) - return - } - - result := convertResponseToTFRollingRelease(response) - - tflog.Info(ctx, "updated project rolling release", map[string]any{ - "team_id": result.TeamID, - "project_id": result.ProjectID, - }) - - diags = resp.State.Set(ctx, result) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - 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 - } - - result := convertResponseToTFRollingRelease(out) - tflog.Info(ctx, "imported project rolling release", map[string]any{ - "team_id": result.TeamID, - "project_id": result.ProjectID, - }) - - diags := resp.State.Set(ctx, result) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } -} From ae54c84a58bd6f56189c694d007e59855b1f54cd Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 6 Jun 2025 14:23:32 -0600 Subject: [PATCH 06/70] [rolling-release] add in docs --- docs/data-sources/project_rolling_release.md | 34 ++++++ docs/resources/project_rolling_release.md | 42 +++++++ terraform.tfstate | 45 ++++++++ terraform.tfstate.backup | 45 ++++++++ test/api_test.go | 114 +++++++++++++++++++ 5 files changed, 280 insertions(+) create mode 100644 docs/data-sources/project_rolling_release.md create mode 100644 docs/resources/project_rolling_release.md create mode 100644 terraform.tfstate create mode 100644 terraform.tfstate.backup create mode 100644 test/api_test.go diff --git a/docs/data-sources/project_rolling_release.md b/docs/data-sources/project_rolling_release.md new file mode 100644 index 00000000..e99a1b54 --- /dev/null +++ b/docs/data-sources/project_rolling_release.md @@ -0,0 +1,34 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_project_rolling_release Data Source - terraform-provider-vercel" +subcategory: "" +description: |- + Provides a Project Rolling Release datasource. + A Project Rolling Release datasource details information about a Rolling Release on a Vercel Project. + For more detailed information, please see the Vercel documentation https://vercel.com/docs/rolling-releases. +--- + +# vercel_project_rolling_release (Data Source) + +Provides a Project Rolling Release datasource. + +A Project Rolling Release datasource details information about a Rolling Release on a Vercel Project. + +For more detailed information, please see the [Vercel documentation](https://vercel.com/docs/rolling-releases). + + + + +## Schema + +### Required + +- `project_id` (String) The ID of the Project for the rolling release + +### Optional + +- `team_id` (String) The ID of the Vercel team. + +### Read-Only + +- `id` (String) The ID of this resource. diff --git a/docs/resources/project_rolling_release.md b/docs/resources/project_rolling_release.md new file mode 100644 index 00000000..657b78cb --- /dev/null +++ b/docs/resources/project_rolling_release.md @@ -0,0 +1,42 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_project_rolling_release Resource - terraform-provider-vercel" +subcategory: "" +description: |- + Provides a Project Rolling release resource. + A Project Rolling release resource defines an Rolling release on a Vercel Project. + For more detailed information, please see the Vercel documentation https://vercel.com/docs/rolling-releases. +--- + +# vercel_project_rolling_release (Resource) + +Provides a Project Rolling release resource. + +A Project Rolling release resource defines an Rolling release on a Vercel Project. + +For more detailed information, please see the [Vercel documentation](https://vercel.com/docs/rolling-releases). + + + + +## Schema + +### Required + +- `project_id` (String) The ID of the Project for the retention policy + +### Optional + +- `advancement_type` (String) The advancement type of the rolling release. Can be 'automatic' or 'manual-approve'. +- `canary_response_header` (Boolean) Whether the canary response header is enabled. This header is used to identify canary deployments. +- `enabled` (Boolean) Whether the rolling release is enabled. +- `stages` (List of Object) A list of stages for the rolling release. Each stage has a target percentage and duration. (see [below for nested schema](#nestedatt--stages)) +- `team_id` (String) The ID of the Vercel team. + + +### Nested Schema for `stages` + +Optional: + +- `duration` (Number) +- `targetPercentage` (Number) diff --git a/terraform.tfstate b/terraform.tfstate new file mode 100644 index 00000000..758586b6 --- /dev/null +++ b/terraform.tfstate @@ -0,0 +1,45 @@ +{ + "version": 4, + "terraform_version": "1.12.1", + "serial": 15, + "lineage": "38bfdd5e-a58a-77ba-9d1b-cbd3f7147262", + "outputs": {}, + "resources": [ + { + "mode": "managed", + "type": "vercel_project_rolling_release", + "name": "example", + "provider": "provider[\"registry.terraform.io/vercel/vercel\"]", + "instances": [ + { + "status": "tainted", + "schema_version": 0, + "attributes": { + "project_id": "prj_9lRsbRoK8DCtxa4CmUu5rWfSaS86", + "rolling_release": { + "advancement_type": "", + "canary_response_header": false, + "enabled": false, + "stages": [ + { + "duration": 300, + "require_approval": false, + "target_percentage": 10 + }, + { + "duration": 0, + "require_approval": false, + "target_percentage": 100 + } + ] + }, + "team_id": "team_4FWx5KQoszRi0ZmM9q9IBoKG" + }, + "sensitive_attributes": [], + "identity_schema_version": 0 + } + ] + } + ], + "check_results": null +} diff --git a/terraform.tfstate.backup b/terraform.tfstate.backup new file mode 100644 index 00000000..8fb194e5 --- /dev/null +++ b/terraform.tfstate.backup @@ -0,0 +1,45 @@ +{ + "version": 4, + "terraform_version": "1.12.1", + "serial": 14, + "lineage": "38bfdd5e-a58a-77ba-9d1b-cbd3f7147262", + "outputs": {}, + "resources": [ + { + "mode": "managed", + "type": "vercel_project_rolling_release", + "name": "example", + "provider": "provider[\"registry.terraform.io/vercel/vercel\"]", + "instances": [ + { + "status": "tainted", + "schema_version": 0, + "attributes": { + "project_id": "prj_9lRsbRoK8DCtxa4CmUu5rWfSaS86", + "rolling_release": { + "advancement_type": "", + "canary_response_header": false, + "enabled": false, + "stages": [ + { + "duration": 300, + "require_approval": false, + "target_percentage": 10 + }, + { + "duration": 0, + "require_approval": false, + "target_percentage": 100 + } + ] + }, + "team_id": "team_4FWx5KQoszRi0ZmM9q9IBoKG" + }, + "sensitive_attributes": [], + "identity_schema_version": 0 + } + ] + } + ], + "check_results": null +} diff --git a/test/api_test.go b/test/api_test.go new file mode 100644 index 00000000..e1af2128 --- /dev/null +++ b/test/api_test.go @@ -0,0 +1,114 @@ +package test + +import ( + "context" + "os" + "testing" + + "github.com/vercel/terraform-provider-vercel/v3/client" +) + +func TestRollingReleaseAPI(t *testing.T) { + token := os.Getenv("VERCEL_API_TOKEN") + if token == "" { + t.Skip("VERCEL_API_TOKEN not set") + } + + c := client.New(token) + ctx := context.Background() + + projectID := "prj_9lRsbRoK8DCtxa4CmUu5rWfSaS86" + teamID := "team_4FWx5KQoszRi0ZmM9q9IBoKG" + + // First, get the current state + current, err := c.GetRollingRelease(ctx, projectID, teamID) + if err != nil { + t.Fatalf("Failed to get current state: %v", err) + } + + t.Logf("Current state: %+v", current) + + // Define default stages that meet API requirements + defaultStages := []client.RollingReleaseStage{ + { + TargetPercentage: 10, + Duration: 60, // 1 hour in minutes + }, + { + TargetPercentage: 50, + Duration: 120, // 2 hours in minutes + }, + { + TargetPercentage: 100, // Final stage must be 100% + }, + } + + // Try different combinations + tests := []struct { + name string + config client.RollingRelease + }{ + { + name: "disable rolling release", + config: client.RollingRelease{ + Enabled: false, + }, + }, + { + name: "enable with automatic advancement", + config: client.RollingRelease{ + Enabled: true, + AdvancementType: "automatic", + Stages: defaultStages, + }, + }, + { + name: "enable with manual approval", + config: client.RollingRelease{ + Enabled: true, + AdvancementType: "manual-approval", + Stages: []client.RollingReleaseStage{ + { + TargetPercentage: 5, + }, + { + TargetPercentage: 25, + }, + { + TargetPercentage: 60, + }, + { + TargetPercentage: 100, + }, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + request := client.UpdateRollingReleaseRequest{ + RollingRelease: tc.config, + ProjectID: projectID, + TeamID: teamID, + } + + // Update the configuration + t.Logf("Sending request: %+v", request) + updated, err := c.UpdateRollingRelease(ctx, request) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + + t.Logf("Update response: %+v", updated) + + // Get the state again to verify + final, err := c.GetRollingRelease(ctx, projectID, teamID) + if err != nil { + t.Fatalf("Failed to get final state: %v", err) + } + + t.Logf("Final state: %+v", final) + }) + } +} \ No newline at end of file From ba41d20d3f1e07b7412dad0afa679bfbde71dbe1 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 6 Jun 2025 14:35:10 -0600 Subject: [PATCH 07/70] [rolling-release] add in docs --- terraform.tfstate | 45 ---------------- terraform.tfstate.backup | 45 ---------------- test/api_test.go | 114 --------------------------------------- 3 files changed, 204 deletions(-) delete mode 100644 terraform.tfstate delete mode 100644 terraform.tfstate.backup delete mode 100644 test/api_test.go diff --git a/terraform.tfstate b/terraform.tfstate deleted file mode 100644 index 758586b6..00000000 --- a/terraform.tfstate +++ /dev/null @@ -1,45 +0,0 @@ -{ - "version": 4, - "terraform_version": "1.12.1", - "serial": 15, - "lineage": "38bfdd5e-a58a-77ba-9d1b-cbd3f7147262", - "outputs": {}, - "resources": [ - { - "mode": "managed", - "type": "vercel_project_rolling_release", - "name": "example", - "provider": "provider[\"registry.terraform.io/vercel/vercel\"]", - "instances": [ - { - "status": "tainted", - "schema_version": 0, - "attributes": { - "project_id": "prj_9lRsbRoK8DCtxa4CmUu5rWfSaS86", - "rolling_release": { - "advancement_type": "", - "canary_response_header": false, - "enabled": false, - "stages": [ - { - "duration": 300, - "require_approval": false, - "target_percentage": 10 - }, - { - "duration": 0, - "require_approval": false, - "target_percentage": 100 - } - ] - }, - "team_id": "team_4FWx5KQoszRi0ZmM9q9IBoKG" - }, - "sensitive_attributes": [], - "identity_schema_version": 0 - } - ] - } - ], - "check_results": null -} diff --git a/terraform.tfstate.backup b/terraform.tfstate.backup deleted file mode 100644 index 8fb194e5..00000000 --- a/terraform.tfstate.backup +++ /dev/null @@ -1,45 +0,0 @@ -{ - "version": 4, - "terraform_version": "1.12.1", - "serial": 14, - "lineage": "38bfdd5e-a58a-77ba-9d1b-cbd3f7147262", - "outputs": {}, - "resources": [ - { - "mode": "managed", - "type": "vercel_project_rolling_release", - "name": "example", - "provider": "provider[\"registry.terraform.io/vercel/vercel\"]", - "instances": [ - { - "status": "tainted", - "schema_version": 0, - "attributes": { - "project_id": "prj_9lRsbRoK8DCtxa4CmUu5rWfSaS86", - "rolling_release": { - "advancement_type": "", - "canary_response_header": false, - "enabled": false, - "stages": [ - { - "duration": 300, - "require_approval": false, - "target_percentage": 10 - }, - { - "duration": 0, - "require_approval": false, - "target_percentage": 100 - } - ] - }, - "team_id": "team_4FWx5KQoszRi0ZmM9q9IBoKG" - }, - "sensitive_attributes": [], - "identity_schema_version": 0 - } - ] - } - ], - "check_results": null -} diff --git a/test/api_test.go b/test/api_test.go deleted file mode 100644 index e1af2128..00000000 --- a/test/api_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package test - -import ( - "context" - "os" - "testing" - - "github.com/vercel/terraform-provider-vercel/v3/client" -) - -func TestRollingReleaseAPI(t *testing.T) { - token := os.Getenv("VERCEL_API_TOKEN") - if token == "" { - t.Skip("VERCEL_API_TOKEN not set") - } - - c := client.New(token) - ctx := context.Background() - - projectID := "prj_9lRsbRoK8DCtxa4CmUu5rWfSaS86" - teamID := "team_4FWx5KQoszRi0ZmM9q9IBoKG" - - // First, get the current state - current, err := c.GetRollingRelease(ctx, projectID, teamID) - if err != nil { - t.Fatalf("Failed to get current state: %v", err) - } - - t.Logf("Current state: %+v", current) - - // Define default stages that meet API requirements - defaultStages := []client.RollingReleaseStage{ - { - TargetPercentage: 10, - Duration: 60, // 1 hour in minutes - }, - { - TargetPercentage: 50, - Duration: 120, // 2 hours in minutes - }, - { - TargetPercentage: 100, // Final stage must be 100% - }, - } - - // Try different combinations - tests := []struct { - name string - config client.RollingRelease - }{ - { - name: "disable rolling release", - config: client.RollingRelease{ - Enabled: false, - }, - }, - { - name: "enable with automatic advancement", - config: client.RollingRelease{ - Enabled: true, - AdvancementType: "automatic", - Stages: defaultStages, - }, - }, - { - name: "enable with manual approval", - config: client.RollingRelease{ - Enabled: true, - AdvancementType: "manual-approval", - Stages: []client.RollingReleaseStage{ - { - TargetPercentage: 5, - }, - { - TargetPercentage: 25, - }, - { - TargetPercentage: 60, - }, - { - TargetPercentage: 100, - }, - }, - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - request := client.UpdateRollingReleaseRequest{ - RollingRelease: tc.config, - ProjectID: projectID, - TeamID: teamID, - } - - // Update the configuration - t.Logf("Sending request: %+v", request) - updated, err := c.UpdateRollingRelease(ctx, request) - if err != nil { - t.Fatalf("Update failed: %v", err) - } - - t.Logf("Update response: %+v", updated) - - // Get the state again to verify - final, err := c.GetRollingRelease(ctx, projectID, teamID) - if err != nil { - t.Fatalf("Failed to get final state: %v", err) - } - - t.Logf("Final state: %+v", final) - }) - } -} \ No newline at end of file From c1370d6e723e3588a8a764093f5a2e8d477cf114 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 6 Jun 2025 14:37:36 -0600 Subject: [PATCH 08/70] [rolling-release] add in docs --- docs/data-sources/project_rolling_release.md | 35 ++++++++++------ docs/resources/project_rolling_release.md | 42 +++++++++++--------- 2 files changed, 45 insertions(+), 32 deletions(-) diff --git a/docs/data-sources/project_rolling_release.md b/docs/data-sources/project_rolling_release.md index e99a1b54..971f58c2 100644 --- a/docs/data-sources/project_rolling_release.md +++ b/docs/data-sources/project_rolling_release.md @@ -3,18 +3,12 @@ page_title: "vercel_project_rolling_release Data Source - terraform-provider-vercel" subcategory: "" description: |- - Provides a Project Rolling Release datasource. - A Project Rolling Release datasource details information about a Rolling Release on a Vercel Project. - For more detailed information, please see the Vercel documentation https://vercel.com/docs/rolling-releases. + Data source for a Vercel project rolling release configuration. --- # vercel_project_rolling_release (Data Source) -Provides a Project Rolling Release datasource. - -A Project Rolling Release datasource details information about a Rolling Release on a Vercel Project. - -For more detailed information, please see the [Vercel documentation](https://vercel.com/docs/rolling-releases). +Data source for a Vercel project rolling release configuration. @@ -23,12 +17,27 @@ For more detailed information, please see the [Vercel documentation](https://ver ### Required -- `project_id` (String) The ID of the Project for the rolling release +- `project_id` (String) The ID of the project. +- `team_id` (String) The ID of the team the project exists in. -### Optional +### Read-Only -- `team_id` (String) The ID of the Vercel team. +- `rolling_release` (Attributes) The rolling release configuration. (see [below for nested schema](#nestedatt--rolling_release)) -### Read-Only + +### 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` + +Read-Only: -- `id` (String) The ID of this resource. +- `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. +- `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 index 657b78cb..17690c45 100644 --- a/docs/resources/project_rolling_release.md +++ b/docs/resources/project_rolling_release.md @@ -3,18 +3,12 @@ page_title: "vercel_project_rolling_release Resource - terraform-provider-vercel" subcategory: "" description: |- - Provides a Project Rolling release resource. - A Project Rolling release resource defines an Rolling release on a Vercel Project. - For more detailed information, please see the Vercel documentation https://vercel.com/docs/rolling-releases. + Manages rolling release configuration for a Vercel project. --- # vercel_project_rolling_release (Resource) -Provides a Project Rolling release resource. - -A Project Rolling release resource defines an Rolling release on a Vercel Project. - -For more detailed information, please see the [Vercel documentation](https://vercel.com/docs/rolling-releases). +Manages rolling release configuration for a Vercel project. @@ -23,20 +17,30 @@ For more detailed information, please see the [Vercel documentation](https://ver ### Required -- `project_id` (String) The ID of the Project for the retention policy +- `project_id` (String) The ID of the project. +- `rolling_release` (Attributes) The rolling release configuration. (see [below for nested schema](#nestedatt--rolling_release)) +- `team_id` (String) The ID of the team the project exists in. + + +### 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)) -### Optional + +### Nested Schema for `rolling_release.stages` -- `advancement_type` (String) The advancement type of the rolling release. Can be 'automatic' or 'manual-approve'. -- `canary_response_header` (Boolean) Whether the canary response header is enabled. This header is used to identify canary deployments. -- `enabled` (Boolean) Whether the rolling release is enabled. -- `stages` (List of Object) A list of stages for the rolling release. Each stage has a target percentage and duration. (see [below for nested schema](#nestedatt--stages)) -- `team_id` (String) The ID of the Vercel team. +Required: - -### Nested Schema for `stages` +- `target_percentage` (Number) The percentage of traffic to route to this stage. Optional: -- `duration` (Number) -- `targetPercentage` (Number) +- `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. +- `require_approval` (Boolean) Whether approval is required before advancing to the next stage. From 53f04f3a842351b79fac9ca05eb3c5d931918a2b Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 6 Jun 2025 14:45:28 -0600 Subject: [PATCH 09/70] Update resource_project_rolling_release.go --- vercel/resource_project_rolling_release.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go index f427e184..aeccd98a 100644 --- a/vercel/resource_project_rolling_release.go +++ b/vercel/resource_project_rolling_release.go @@ -471,14 +471,14 @@ func (r *projectRollingReleaseResource) Read(ctx context.Context, req resource.R if err != nil { resp.Diagnostics.AddError( "Error reading project rolling release", - fmt.Sprintf("Could not get project rolling release %s %s, unexpected error: %s", + fmt.Sprintf("Could not get project rolling release, unexpected error: %s", err, ), ) return } - result, diags := convertResponseToTFRollingRelease(out, nil, ctx) + result, _ := convertResponseToTFRollingRelease(out, nil, ctx) tflog.Info(ctx, "read project rolling release", map[string]any{ "team_id": result.TeamID.ValueString(), "project_id": result.ProjectID.ValueString(), @@ -592,7 +592,7 @@ func (r *projectRollingReleaseResource) ImportState(ctx context.Context, req res return } - result, diags := convertResponseToTFRollingRelease(out, nil, ctx) + result, _ := convertResponseToTFRollingRelease(out, nil, ctx) tflog.Info(ctx, "imported project rolling release", map[string]any{ "team_id": result.TeamID.ValueString(), "project_id": result.ProjectID.ValueString(), From 2796b4b0a675ff60b46d710e1761759a007f7553 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Mon, 9 Jun 2025 15:36:31 -0400 Subject: [PATCH 10/70] fix error --- vercel/resource_project_rolling_release.go | 106 ++++++++++----------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go index aeccd98a..f58a14bf 100644 --- a/vercel/resource_project_rolling_release.go +++ b/vercel/resource_project_rolling_release.go @@ -4,14 +4,14 @@ import ( "context" "fmt" + "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/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/vercel/terraform-provider-vercel/v3/client" - "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-framework/attr" ) var ( @@ -81,47 +81,47 @@ func (r *projectRollingReleaseResource) Schema(ctx context.Context, _ resource.S Attributes: map[string]schema.Attribute{ "project_id": schema.StringAttribute{ MarkdownDescription: "The ID of the project.", - Required: true, + Required: true, }, "team_id": schema.StringAttribute{ MarkdownDescription: "The ID of the team the project exists in.", - Required: true, + Required: true, }, "rolling_release": schema.SingleNestedAttribute{ MarkdownDescription: "The rolling release configuration.", - Required: true, + Required: true, Attributes: map[string]schema.Attribute{ "enabled": schema.BoolAttribute{ MarkdownDescription: "Whether rolling releases are enabled.", - Required: true, + 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, + 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, + 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, + Required: 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, - Computed: true, + Optional: true, + Computed: true, }, "require_approval": schema.BoolAttribute{ MarkdownDescription: "Whether approval is required before advancing to the next stage.", - Optional: true, - Computed: true, + Optional: true, + Computed: true, }, }, }, @@ -133,16 +133,16 @@ func (r *projectRollingReleaseResource) Schema(ctx context.Context, _ resource.S } type TFRollingReleaseStage struct { - TargetPercentage types.Int64 `tfsdk:"target_percentage"` - Duration types.Int64 `tfsdk:"duration"` - RequireApproval types.Bool `tfsdk:"require_approval"` + TargetPercentage types.Int64 `tfsdk:"target_percentage"` + Duration types.Int64 `tfsdk:"duration"` + RequireApproval types.Bool `tfsdk:"require_approval"` } // TFRollingRelease reflects the state terraform stores internally for a project rolling release. type TFRollingRelease struct { - Enabled types.Bool `tfsdk:"enabled"` - AdvancementType types.String `tfsdk:"advancement_type"` - Stages types.List `tfsdk:"stages"` + 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. @@ -154,13 +154,13 @@ type TFRollingReleaseInfo struct { type RollingReleaseStage struct { TargetPercentage int `json:"targetPercentage"` - Duration *int `json:"duration,omitempty"` - RequireApproval bool `json:"requireApproval"` + Duration *int `json:"duration,omitempty"` + RequireApproval bool `json:"requireApproval"` } type RollingRelease struct { - Enabled bool `json:"enabled"` - AdvancementType string `json:"advancementType"` + Enabled bool `json:"enabled"` + AdvancementType string `json:"advancementType"` Stages []RollingReleaseStage `json:"stages"` } @@ -243,21 +243,21 @@ func convertStages(stages []client.RollingReleaseStage, advancementType string, elements[i] = types.ObjectValueMust( map[string]attr.Type{ "target_percentage": types.Int64Type, - "duration": types.Int64Type, - "require_approval": types.BoolType, + "duration": types.Int64Type, + "require_approval": types.BoolType, }, map[string]attr.Value{ "target_percentage": stage.TargetPercentage, - "duration": duration, - "require_approval": stage.RequireApproval, + "duration": duration, + "require_approval": stage.RequireApproval, }, ) } return types.ListValueFrom(ctx, types.ObjectType{ AttrTypes: map[string]attr.Type{ "target_percentage": types.Int64Type, - "duration": types.Int64Type, - "require_approval": types.BoolType, + "duration": types.Int64Type, + "require_approval": types.BoolType, }, }, elements) } @@ -267,8 +267,8 @@ func convertStages(stages []client.RollingReleaseStage, advancementType string, return types.ListValueFrom(ctx, types.ObjectType{ AttrTypes: map[string]attr.Type{ "target_percentage": types.Int64Type, - "duration": types.Int64Type, - "require_approval": types.BoolType, + "duration": types.Int64Type, + "require_approval": types.BoolType, }, }, []attr.Value{}) } @@ -283,7 +283,7 @@ func convertStages(stages []client.RollingReleaseStage, advancementType string, if i < len(planStages) { targetPercentage = planStages[i].TargetPercentage requireApproval = planStages[i].RequireApproval - + // Handle duration based on advancement type if advancementType == "automatic" { if planStages[i].Duration.IsUnknown() { @@ -315,13 +315,13 @@ func convertStages(stages []client.RollingReleaseStage, advancementType string, elements[i] = types.ObjectValueMust( map[string]attr.Type{ "target_percentage": types.Int64Type, - "duration": types.Int64Type, - "require_approval": types.BoolType, + "duration": types.Int64Type, + "require_approval": types.BoolType, }, map[string]attr.Value{ "target_percentage": targetPercentage, - "duration": duration, - "require_approval": requireApproval, + "duration": duration, + "require_approval": requireApproval, }, ) } @@ -329,8 +329,8 @@ func convertStages(stages []client.RollingReleaseStage, advancementType string, return types.ListValueFrom(ctx, types.ObjectType{ AttrTypes: map[string]attr.Type{ "target_percentage": types.Int64Type, - "duration": types.Int64Type, - "require_approval": types.BoolType, + "duration": types.Int64Type, + "require_approval": types.BoolType, }, }, elements) } @@ -391,11 +391,11 @@ func (r *projectRollingReleaseResource) Create(ctx context.Context, req resource } tflog.Debug(ctx, "Got plan from request", map[string]any{ - "project_id": plan.ProjectID.ValueString(), - "team_id": plan.TeamID.ValueString(), - "enabled": plan.RollingRelease.Enabled.ValueBool(), + "project_id": plan.ProjectID.ValueString(), + "team_id": plan.TeamID.ValueString(), + "enabled": plan.RollingRelease.Enabled.ValueBool(), "advancement_type": plan.RollingRelease.AdvancementType.ValueString(), - "stages": plan.RollingRelease.Stages, + "stages": plan.RollingRelease.Stages, }) _, err := r.client.GetProject(ctx, plan.ProjectID.ValueString(), plan.TeamID.ValueString()) @@ -439,11 +439,11 @@ func (r *projectRollingReleaseResource) Create(ctx context.Context, req resource // Log the values for debugging tflog.Debug(ctx, "created project rolling release", map[string]any{ - "team_id": result.TeamID.ValueString(), - "project_id": result.ProjectID.ValueString(), - "enabled": result.RollingRelease.Enabled.ValueBool(), + "team_id": result.TeamID.ValueString(), + "project_id": result.ProjectID.ValueString(), + "enabled": result.RollingRelease.Enabled.ValueBool(), "advancement_type": result.RollingRelease.AdvancementType.ValueString(), - "stages": result.RollingRelease.Stages, + "stages": result.RollingRelease.Stages, }) diags = resp.State.Set(ctx, result) @@ -554,11 +554,11 @@ func (r *projectRollingReleaseResource) Update(ctx context.Context, req resource // Log the values for debugging tflog.Debug(ctx, "updated project rolling release", map[string]any{ - "team_id": result.TeamID.ValueString(), - "project_id": result.ProjectID.ValueString(), - "enabled": result.RollingRelease.Enabled.ValueBool(), + "team_id": result.TeamID.ValueString(), + "project_id": result.ProjectID.ValueString(), + "enabled": result.RollingRelease.Enabled.ValueBool(), "advancement_type": result.RollingRelease.AdvancementType.ValueString(), - "stages": result.RollingRelease.Stages, + "stages": result.RollingRelease.Stages, }) diags = resp.State.Set(ctx, result) @@ -598,7 +598,7 @@ func (r *projectRollingReleaseResource) ImportState(ctx context.Context, req res "project_id": result.ProjectID.ValueString(), }) - diags = resp.State.Set(ctx, result) + diags := resp.State.Set(ctx, result) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return From 717d6de5cc3729c240ba77bf84c9ba8ca90a84f9 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Mon, 9 Jun 2025 15:48:55 -0400 Subject: [PATCH 11/70] gofmt --- vercel/data_source_project_rolling_release.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/vercel/data_source_project_rolling_release.go b/vercel/data_source_project_rolling_release.go index 93e99066..4523c412 100644 --- a/vercel/data_source_project_rolling_release.go +++ b/vercel/data_source_project_rolling_release.go @@ -51,40 +51,40 @@ func (d *projectRollingReleaseDataSource) Schema(ctx context.Context, _ datasour Attributes: map[string]schema.Attribute{ "project_id": schema.StringAttribute{ MarkdownDescription: "The ID of the project.", - Required: true, + Required: true, }, "team_id": schema.StringAttribute{ MarkdownDescription: "The ID of the team the project exists in.", - Required: true, + Required: true, }, "rolling_release": schema.SingleNestedAttribute{ MarkdownDescription: "The rolling release configuration.", - Computed: true, + Computed: true, Attributes: map[string]schema.Attribute{ "enabled": schema.BoolAttribute{ MarkdownDescription: "Whether rolling releases are enabled.", - Computed: true, + 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, + Computed: true, }, "stages": schema.ListNestedAttribute{ MarkdownDescription: "The stages of the rolling release. Required when enabled is true.", - Computed: 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, + 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.", - Computed: true, + Computed: true, }, "require_approval": schema.BoolAttribute{ MarkdownDescription: "Whether approval is required before advancing to the next stage.", - Computed: true, + Computed: true, }, }, }, From dab88e5f554a326d0fbf99f142e8fcc5c4b25459 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Mon, 9 Jun 2025 15:53:58 -0400 Subject: [PATCH 12/70] gofmt --- client/project_rolling_release.go | 68 +++++++++++++++---------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/client/project_rolling_release.go b/client/project_rolling_release.go index 6662a70d..36640361 100644 --- a/client/project_rolling_release.go +++ b/client/project_rolling_release.go @@ -10,16 +10,16 @@ import ( // 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 + 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 + 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 } // ErrorResponse represents the error response from the Vercel API @@ -94,10 +94,10 @@ func (c *Client) GetRollingRelease(ctx context.Context, projectID, teamID string url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, projectID, teamID) tflog.Debug(ctx, "getting rolling-release configuration", map[string]any{ - "url": url, - "method": "GET", + "url": url, + "method": "GET", "project_id": projectID, - "team_id": teamID, + "team_id": teamID, }) d := RollingReleaseInfo{} @@ -140,7 +140,7 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling // Other stages can have all properties stageMap := map[string]any{ "targetPercentage": stage.TargetPercentage, - "requireApproval": stage.RequireApproval, + "requireApproval": stage.RequireApproval, } // Only include duration if it's set if stage.Duration != nil { @@ -158,15 +158,15 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling })) tflog.Debug(ctx, "updating rolling-release configuration", map[string]any{ - "url": url, - "method": "PATCH", - "project_id": request.ProjectID, - "team_id": request.TeamID, - "payload": payload, - "base_url": c.baseURL, - "enabled": request.RollingRelease.Enabled, + "url": url, + "method": "PATCH", + "project_id": request.ProjectID, + "team_id": request.TeamID, + "payload": payload, + "base_url": c.baseURL, + "enabled": request.RollingRelease.Enabled, "advancement_type": request.RollingRelease.AdvancementType, - "stages_count": len(request.RollingRelease.Stages), + "stages_count": len(request.RollingRelease.Stages), }) // Log each stage for debugging @@ -194,19 +194,19 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling var errResp ErrorResponse if resp != "" && json.Unmarshal([]byte(resp), &errResp) == nil { tflog.Error(ctx, "error updating rolling-release", map[string]any{ - "error_code": errResp.Error.Code, + "error_code": errResp.Error.Code, "error_message": errResp.Error.Message, - "url": url, - "payload": payload, - "response": resp, + "url": url, + "payload": payload, + "response": resp, }) return d, fmt.Errorf("failed to update rolling release: %s - %s", errResp.Error.Code, errResp.Error.Message) } tflog.Error(ctx, "error updating rolling-release", map[string]any{ - "error": err.Error(), - "url": url, - "payload": payload, + "error": err.Error(), + "url": url, + "payload": payload, "response": resp, }) return d, fmt.Errorf("failed to update rolling release: %w", err) @@ -215,11 +215,11 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling // Return the request state since we know it's valid result := RollingReleaseInfo{ ProjectID: request.ProjectID, - TeamID: request.TeamID, + TeamID: request.TeamID, RollingRelease: RollingRelease{ - Enabled: request.RollingRelease.Enabled, + Enabled: request.RollingRelease.Enabled, AdvancementType: request.RollingRelease.AdvancementType, - Stages: make([]RollingReleaseStage, len(request.RollingRelease.Stages)), + Stages: make([]RollingReleaseStage, len(request.RollingRelease.Stages)), }, } @@ -238,11 +238,11 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling } tflog.Debug(ctx, "returning rolling release configuration", map[string]any{ - "project_id": result.ProjectID, - "team_id": result.TeamID, - "enabled": result.RollingRelease.Enabled, + "project_id": result.ProjectID, + "team_id": result.TeamID, + "enabled": result.RollingRelease.Enabled, "advancement_type": result.RollingRelease.AdvancementType, - "stages": result.RollingRelease.Stages, + "stages": result.RollingRelease.Stages, }) return result, nil @@ -253,10 +253,10 @@ func (c *Client) DeleteRollingRelease(ctx context.Context, projectID, teamID str url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, projectID, teamID) tflog.Debug(ctx, "deleting rolling-release configuration", map[string]any{ - "url": url, - "method": "DELETE", + "url": url, + "method": "DELETE", "project_id": projectID, - "team_id": teamID, + "team_id": teamID, }) var d RollingReleaseInfo From d4f277cecc5c97213ec768ce0f76779fbcfe7898 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Mon, 9 Jun 2025 21:14:15 -0400 Subject: [PATCH 13/70] rr test --- .../resource_project_rolling_release_test.go | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 vercel/resource_project_rolling_release_test.go diff --git a/vercel/resource_project_rolling_release_test.go b/vercel/resource_project_rolling_release_test.go new file mode 100644 index 00000000..02c3f689 --- /dev/null +++ b/vercel/resource_project_rolling_release_test.go @@ -0,0 +1,214 @@ +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, testGithubRepo(t))), + Check: resource.ComposeAggregateTestCheckFunc( + 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.#", "3"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ + "duration": "1", + "require_approval": "false", + "target_percentage": "15", + }), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ + "duration": "1", + "require_approval": "false", + "target_percentage": "50", + }), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ + "duration": "1", + "require_approval": "false", + "target_percentage": "100", + }), + resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.0.id"), + resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.1.id"), + resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.2.id"), + ), + }, + { + Config: cfg(testAccProjectRollingReleasesConfigUpdate(nameSuffix, testGithubRepo(t))), + Check: resource.ComposeAggregateTestCheckFunc( + 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{ + "duration": "10", + "require_approval": "false", + "target_percentage": "15", + }), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ + "duration": "1", + "require_approval": "false", + "target_percentage": "55", + }), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ + "duration": "100", + "require_approval": "false", + "target_percentage": "80", + }), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ + "duration": "1", + "require_approval": "false", + "target_percentage": "100", + }), + resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.0.id"), + resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.1.id"), + resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.2.id"), + resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.3.id"), + ), + }, + { + Config: cfg(testAccProjectRollingReleasesConfigOff(nameSuffix, testGithubRepo(t))), + Check: resource.ComposeAggregateTestCheckFunc( + testAccProjectRollingReleaseExists(testClient(t), resourceName, testTeam(t)), + resource.TestCheckResourceAttr(resourceName, "rolling_release.enabled", "false"), + ), + }, + }, + }) +} + +func testAccProjectRollingReleasesConfig(projectName string, githubRepo string) string { + return fmt.Sprintf(` +resource "vercel_project" "example" { + name = "test-acc-example-project-%[1]s" + + git_repository = { + type = "github" + repo = "%[2]s" + } +} + +resource "vercel_project_rolling_release" "example" { + project_id = vercel_project.example.id + team_id = vercel_project.example.team_id + rolling_release = { + enabled = true + advancement_type = "automatic" + stages = [ + { + duration = 1 + require_approval = false + target_percentage = 15 + }, + { + duration = 1 + require_approval = false + target_percentage = 50 + }, + { + duration = 1 + require_approval = false + target_percentage = 100 + } + ] + } +} +`, projectName, githubRepo) +} + +func testAccProjectRollingReleasesConfigUpdate(projectName string, githubRepo string) string { + return fmt.Sprintf(` +resource "vercel_project" "example" { + name = "test-acc-example-project-%[1]s" + + git_repository = { + type = "github" + repo = "%[2]s" + } +} + +resource "vercel_project_rolling_release" "example" { + project_id = vercel_project.example.id + team_id = vercel_project.example.team_id + rolling_release = { + enabled = true + advancement_type = "automatic" + stages = [ + { + duration = 10 + require_approval = false + target_percentage = 15 + }, + { + duration = 1 + require_approval = false + target_percentage = 55 + }, + { + duration = 100 + require_approval = false + target_percentage = 85 + }, + { + duration = 1 + require_approval = false + target_percentage = 100 + } + ] + } +} +`, projectName, githubRepo) +} + +func testAccProjectRollingReleasesConfigOff(projectName string, githubRepo string) string { + return fmt.Sprintf(` +resource "vercel_project" "example" { + name = "test-acc-example-project-%[1]s" + + git_repository = { + type = "github" + repo = "%[2]s" + } +} + +resource "vercel_project_rolling_release" "example" { + project_id = vercel_project.example.id + team_id = vercel_project.example.team_id + rolling_release = { + enabled = false + } +} +`, projectName, githubRepo) +} From e4172a67a54817839513a73fd2a74c29522eaf17 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Tue, 10 Jun 2025 10:18:26 -0400 Subject: [PATCH 14/70] fixes resource name --- .../resource_project_rolling_release_test.go | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/vercel/resource_project_rolling_release_test.go b/vercel/resource_project_rolling_release_test.go index 02c3f689..b8e4922d 100644 --- a/vercel/resource_project_rolling_release_test.go +++ b/vercel/resource_project_rolling_release_test.go @@ -40,69 +40,69 @@ func TestAcc_ProjectRollingRelease(t *testing.T) { Config: cfg(testAccProjectRollingReleasesConfig(nameSuffix, testGithubRepo(t))), Check: resource.ComposeAggregateTestCheckFunc( testAccProjectRollingReleaseExists(testClient(t), resourceName, testTeam(t)), - resource.TestCheckResourceAttr(resourceName, "rolling_release.enabled", "true"), - resource.TestCheckResourceAttr(resourceName, "rolling_release.advancement_type", "automatic"), + resource.TestCheckResourceAttr(resourceName, "vercel_project_rolling_release.enabled", "true"), + resource.TestCheckResourceAttr(resourceName, "vercel_project_rolling_release.advancement_type", "automatic"), - resource.TestCheckResourceAttr(resourceName, "rolling_release.stages.#", "3"), - resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ + resource.TestCheckResourceAttr(resourceName, "vercel_project_rolling_release.stages.#", "3"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "vercel_project_rolling_release.stages.*", map[string]string{ "duration": "1", "require_approval": "false", "target_percentage": "15", }), - resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "vercel_project_rolling_release.stages.*", map[string]string{ "duration": "1", "require_approval": "false", "target_percentage": "50", }), - resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "vercel_project_rolling_release.stages.*", map[string]string{ "duration": "1", "require_approval": "false", "target_percentage": "100", }), - resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.0.id"), - resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.1.id"), - resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.2.id"), + resource.TestCheckResourceAttrSet(resourceName, "vercel_project_rolling_release.stages.0.id"), + resource.TestCheckResourceAttrSet(resourceName, "vercel_project_rolling_release.stages.1.id"), + resource.TestCheckResourceAttrSet(resourceName, "vercel_project_rolling_release.stages.2.id"), ), }, { Config: cfg(testAccProjectRollingReleasesConfigUpdate(nameSuffix, testGithubRepo(t))), Check: resource.ComposeAggregateTestCheckFunc( testAccProjectRollingReleaseExists(testClient(t), resourceName, testTeam(t)), - resource.TestCheckResourceAttr(resourceName, "rolling_release.enabled", "true"), - resource.TestCheckResourceAttr(resourceName, "rolling_release.advancement_type", "automatic"), + resource.TestCheckResourceAttr(resourceName, "vercel_project_rolling_release.enabled", "true"), + resource.TestCheckResourceAttr(resourceName, "vercel_project_rolling_release.advancement_type", "automatic"), - resource.TestCheckResourceAttr(resourceName, "rolling_release.stages.#", "4"), - resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ + resource.TestCheckResourceAttr(resourceName, "vercel_project_rolling_release.stages.#", "4"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "vercel_project_rolling_release.stages.*", map[string]string{ "duration": "10", "require_approval": "false", "target_percentage": "15", }), - resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "vercel_project_rolling_release.stages.*", map[string]string{ "duration": "1", "require_approval": "false", "target_percentage": "55", }), - resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "vercel_project_rolling_release.stages.*", map[string]string{ "duration": "100", "require_approval": "false", "target_percentage": "80", }), - resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "vercel_project_rolling_release.stages.*", map[string]string{ "duration": "1", "require_approval": "false", "target_percentage": "100", }), - resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.0.id"), - resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.1.id"), - resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.2.id"), - resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.3.id"), + resource.TestCheckResourceAttrSet(resourceName, "vercel_project_rolling_release.stages.0.id"), + resource.TestCheckResourceAttrSet(resourceName, "vercel_project_rolling_release.stages.1.id"), + resource.TestCheckResourceAttrSet(resourceName, "vercel_project_rolling_release.stages.2.id"), + resource.TestCheckResourceAttrSet(resourceName, "vercel_project_rolling_release.stages.3.id"), ), }, { Config: cfg(testAccProjectRollingReleasesConfigOff(nameSuffix, testGithubRepo(t))), Check: resource.ComposeAggregateTestCheckFunc( testAccProjectRollingReleaseExists(testClient(t), resourceName, testTeam(t)), - resource.TestCheckResourceAttr(resourceName, "rolling_release.enabled", "false"), + resource.TestCheckResourceAttr(resourceName, "vercel_project_rolling_release.enabled", "false"), ), }, }, From 2708ab118eb5a76fe501dba53382c102368f429a Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Tue, 10 Jun 2025 11:29:42 -0400 Subject: [PATCH 15/70] resource name --- .../resource_project_rolling_release_test.go | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/vercel/resource_project_rolling_release_test.go b/vercel/resource_project_rolling_release_test.go index b8e4922d..cb9e98bb 100644 --- a/vercel/resource_project_rolling_release_test.go +++ b/vercel/resource_project_rolling_release_test.go @@ -40,69 +40,69 @@ func TestAcc_ProjectRollingRelease(t *testing.T) { Config: cfg(testAccProjectRollingReleasesConfig(nameSuffix, testGithubRepo(t))), Check: resource.ComposeAggregateTestCheckFunc( testAccProjectRollingReleaseExists(testClient(t), resourceName, testTeam(t)), - resource.TestCheckResourceAttr(resourceName, "vercel_project_rolling_release.enabled", "true"), - resource.TestCheckResourceAttr(resourceName, "vercel_project_rolling_release.advancement_type", "automatic"), + resource.TestCheckResourceAttr(resourceName, "enabled", "true"), + resource.TestCheckResourceAttr(resourceName, "advancement_type", "automatic"), - resource.TestCheckResourceAttr(resourceName, "vercel_project_rolling_release.stages.#", "3"), - resource.TestCheckTypeSetElemNestedAttrs(resourceName, "vercel_project_rolling_release.stages.*", map[string]string{ + resource.TestCheckResourceAttr(resourceName, "rolling_release.stages.#", "3"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ "duration": "1", "require_approval": "false", "target_percentage": "15", }), - resource.TestCheckTypeSetElemNestedAttrs(resourceName, "vercel_project_rolling_release.stages.*", map[string]string{ + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ "duration": "1", "require_approval": "false", "target_percentage": "50", }), - resource.TestCheckTypeSetElemNestedAttrs(resourceName, "vercel_project_rolling_release.stages.*", map[string]string{ + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ "duration": "1", "require_approval": "false", "target_percentage": "100", }), - resource.TestCheckResourceAttrSet(resourceName, "vercel_project_rolling_release.stages.0.id"), - resource.TestCheckResourceAttrSet(resourceName, "vercel_project_rolling_release.stages.1.id"), - resource.TestCheckResourceAttrSet(resourceName, "vercel_project_rolling_release.stages.2.id"), + resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.0.id"), + resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.1.id"), + resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.2.id"), ), }, { Config: cfg(testAccProjectRollingReleasesConfigUpdate(nameSuffix, testGithubRepo(t))), Check: resource.ComposeAggregateTestCheckFunc( testAccProjectRollingReleaseExists(testClient(t), resourceName, testTeam(t)), - resource.TestCheckResourceAttr(resourceName, "vercel_project_rolling_release.enabled", "true"), - resource.TestCheckResourceAttr(resourceName, "vercel_project_rolling_release.advancement_type", "automatic"), + resource.TestCheckResourceAttr(resourceName, "enabled", "true"), + resource.TestCheckResourceAttr(resourceName, "advancement_type", "automatic"), - resource.TestCheckResourceAttr(resourceName, "vercel_project_rolling_release.stages.#", "4"), - resource.TestCheckTypeSetElemNestedAttrs(resourceName, "vercel_project_rolling_release.stages.*", map[string]string{ + resource.TestCheckResourceAttr(resourceName, "rolling_release.stages.#", "4"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ "duration": "10", "require_approval": "false", "target_percentage": "15", }), - resource.TestCheckTypeSetElemNestedAttrs(resourceName, "vercel_project_rolling_release.stages.*", map[string]string{ + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ "duration": "1", "require_approval": "false", "target_percentage": "55", }), - resource.TestCheckTypeSetElemNestedAttrs(resourceName, "vercel_project_rolling_release.stages.*", map[string]string{ + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ "duration": "100", "require_approval": "false", "target_percentage": "80", }), - resource.TestCheckTypeSetElemNestedAttrs(resourceName, "vercel_project_rolling_release.stages.*", map[string]string{ + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ "duration": "1", "require_approval": "false", "target_percentage": "100", }), - resource.TestCheckResourceAttrSet(resourceName, "vercel_project_rolling_release.stages.0.id"), - resource.TestCheckResourceAttrSet(resourceName, "vercel_project_rolling_release.stages.1.id"), - resource.TestCheckResourceAttrSet(resourceName, "vercel_project_rolling_release.stages.2.id"), - resource.TestCheckResourceAttrSet(resourceName, "vercel_project_rolling_release.stages.3.id"), + resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.0.id"), + resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.1.id"), + resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.2.id"), + resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.3.id"), ), }, { Config: cfg(testAccProjectRollingReleasesConfigOff(nameSuffix, testGithubRepo(t))), Check: resource.ComposeAggregateTestCheckFunc( testAccProjectRollingReleaseExists(testClient(t), resourceName, testTeam(t)), - resource.TestCheckResourceAttr(resourceName, "vercel_project_rolling_release.enabled", "false"), + resource.TestCheckResourceAttr(resourceName, "enabled", "false"), ), }, }, From 71edceff86f1561bb9dc175c8e4498cab842030b Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Wed, 11 Jun 2025 08:20:22 -0400 Subject: [PATCH 16/70] removes test --- .../resource_project_rolling_release_test.go | 214 ------------------ 1 file changed, 214 deletions(-) delete mode 100644 vercel/resource_project_rolling_release_test.go diff --git a/vercel/resource_project_rolling_release_test.go b/vercel/resource_project_rolling_release_test.go deleted file mode 100644 index cb9e98bb..00000000 --- a/vercel/resource_project_rolling_release_test.go +++ /dev/null @@ -1,214 +0,0 @@ -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, testGithubRepo(t))), - Check: resource.ComposeAggregateTestCheckFunc( - testAccProjectRollingReleaseExists(testClient(t), resourceName, testTeam(t)), - resource.TestCheckResourceAttr(resourceName, "enabled", "true"), - resource.TestCheckResourceAttr(resourceName, "advancement_type", "automatic"), - - resource.TestCheckResourceAttr(resourceName, "rolling_release.stages.#", "3"), - resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ - "duration": "1", - "require_approval": "false", - "target_percentage": "15", - }), - resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ - "duration": "1", - "require_approval": "false", - "target_percentage": "50", - }), - resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ - "duration": "1", - "require_approval": "false", - "target_percentage": "100", - }), - resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.0.id"), - resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.1.id"), - resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.2.id"), - ), - }, - { - Config: cfg(testAccProjectRollingReleasesConfigUpdate(nameSuffix, testGithubRepo(t))), - Check: resource.ComposeAggregateTestCheckFunc( - testAccProjectRollingReleaseExists(testClient(t), resourceName, testTeam(t)), - resource.TestCheckResourceAttr(resourceName, "enabled", "true"), - resource.TestCheckResourceAttr(resourceName, "advancement_type", "automatic"), - - resource.TestCheckResourceAttr(resourceName, "rolling_release.stages.#", "4"), - resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ - "duration": "10", - "require_approval": "false", - "target_percentage": "15", - }), - resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ - "duration": "1", - "require_approval": "false", - "target_percentage": "55", - }), - resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ - "duration": "100", - "require_approval": "false", - "target_percentage": "80", - }), - resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ - "duration": "1", - "require_approval": "false", - "target_percentage": "100", - }), - resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.0.id"), - resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.1.id"), - resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.2.id"), - resource.TestCheckResourceAttrSet(resourceName, "rolling_release.stages.3.id"), - ), - }, - { - Config: cfg(testAccProjectRollingReleasesConfigOff(nameSuffix, testGithubRepo(t))), - Check: resource.ComposeAggregateTestCheckFunc( - testAccProjectRollingReleaseExists(testClient(t), resourceName, testTeam(t)), - resource.TestCheckResourceAttr(resourceName, "enabled", "false"), - ), - }, - }, - }) -} - -func testAccProjectRollingReleasesConfig(projectName string, githubRepo string) string { - return fmt.Sprintf(` -resource "vercel_project" "example" { - name = "test-acc-example-project-%[1]s" - - git_repository = { - type = "github" - repo = "%[2]s" - } -} - -resource "vercel_project_rolling_release" "example" { - project_id = vercel_project.example.id - team_id = vercel_project.example.team_id - rolling_release = { - enabled = true - advancement_type = "automatic" - stages = [ - { - duration = 1 - require_approval = false - target_percentage = 15 - }, - { - duration = 1 - require_approval = false - target_percentage = 50 - }, - { - duration = 1 - require_approval = false - target_percentage = 100 - } - ] - } -} -`, projectName, githubRepo) -} - -func testAccProjectRollingReleasesConfigUpdate(projectName string, githubRepo string) string { - return fmt.Sprintf(` -resource "vercel_project" "example" { - name = "test-acc-example-project-%[1]s" - - git_repository = { - type = "github" - repo = "%[2]s" - } -} - -resource "vercel_project_rolling_release" "example" { - project_id = vercel_project.example.id - team_id = vercel_project.example.team_id - rolling_release = { - enabled = true - advancement_type = "automatic" - stages = [ - { - duration = 10 - require_approval = false - target_percentage = 15 - }, - { - duration = 1 - require_approval = false - target_percentage = 55 - }, - { - duration = 100 - require_approval = false - target_percentage = 85 - }, - { - duration = 1 - require_approval = false - target_percentage = 100 - } - ] - } -} -`, projectName, githubRepo) -} - -func testAccProjectRollingReleasesConfigOff(projectName string, githubRepo string) string { - return fmt.Sprintf(` -resource "vercel_project" "example" { - name = "test-acc-example-project-%[1]s" - - git_repository = { - type = "github" - repo = "%[2]s" - } -} - -resource "vercel_project_rolling_release" "example" { - project_id = vercel_project.example.id - team_id = vercel_project.example.team_id - rolling_release = { - enabled = false - } -} -`, projectName, githubRepo) -} From 374a3724fb24d1676326c4dca2927b02d7f14089 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Wed, 11 Jun 2025 15:39:37 -0600 Subject: [PATCH 17/70] [rolling-release] --- client/project_rolling_release.go | 285 ++++---- main.tf | 32 + vercel/data_source_project_rolling_release.go | 10 +- vercel/resource_project_rolling_release.go | 617 ++++++++++++------ .../resource_project_rolling_release_test.go | 214 ++++++ 5 files changed, 806 insertions(+), 352 deletions(-) create mode 100644 main.tf create mode 100644 vercel/resource_project_rolling_release_test.go diff --git a/client/project_rolling_release.go b/client/project_rolling_release.go index 36640361..be1ce90c 100644 --- a/client/project_rolling_release.go +++ b/client/project_rolling_release.go @@ -4,8 +4,8 @@ import ( "context" "encoding/json" "fmt" - - "github.com/hashicorp/terraform-plugin-log/tflog" + "sort" + "time" ) // RollingReleaseStage represents a stage in a rolling release @@ -52,18 +52,17 @@ func (r *RollingRelease) Validate() error { return fmt.Errorf("must have between 2 and 10 stages when enabled is true, got: %d", len(r.Stages)) } - // Validate last stage is 100% - lastStage := r.Stages[len(r.Stages)-1] - if lastStage.TargetPercentage != 100 { - return fmt.Errorf("last stage must have target_percentage=100, got: %d", lastStage.TargetPercentage) - } + // Sort stages by target percentage to ensure correct order + sort.Slice(r.Stages, func(i, j int) bool { + return r.Stages[i].TargetPercentage < r.Stages[j].TargetPercentage + }) // Validate stages are in ascending order and within bounds prevPercentage := 0 for i, stage := range r.Stages { // Validate percentage bounds - if stage.TargetPercentage < 1 || stage.TargetPercentage > 100 { - return fmt.Errorf("stage %d: target_percentage must be between 1 and 100, got: %d", i, stage.TargetPercentage) + if stage.TargetPercentage < 0 || stage.TargetPercentage > 100 { + return fmt.Errorf("stage %d: target_percentage must be between 0 and 100, got: %d", i, stage.TargetPercentage) } // Validate ascending order @@ -74,12 +73,32 @@ func (r *RollingRelease) Validate() error { // Validate duration for automatic advancement if r.AdvancementType == "automatic" { - if stage.Duration == nil || *stage.Duration < 1 || *stage.Duration > 10000 { - return fmt.Errorf("stage %d: duration must be between 1 and 10000 minutes for automatic advancement, got: %d", i, *stage.Duration) + if i < len(r.Stages)-1 { // All stages except last need duration + if stage.Duration == nil { + return fmt.Errorf("stage %d: duration is required for automatic advancement (except for the last stage)", i) + } + if *stage.Duration < 1 || *stage.Duration > 10000 { + return fmt.Errorf("stage %d: duration must be between 1 and 10000 minutes for automatic advancement, got: %d", i, *stage.Duration) + } + } else { // Last stage should not have duration + if stage.Duration != nil { + return fmt.Errorf("stage %d: last stage should not have duration for automatic advancement", i) + } + } + } else { + // For manual approval, no stages should have duration + if stage.Duration != nil { + return fmt.Errorf("stage %d: duration should not be set for manual-approval advancement type", i) } } } + // Validate last stage is 100% + lastStage := r.Stages[len(r.Stages)-1] + if lastStage.TargetPercentage != 100 { + return fmt.Errorf("last stage must have target_percentage=100, got: %d", lastStage.TargetPercentage) + } + return nil } @@ -93,22 +112,25 @@ type RollingReleaseInfo struct { func (c *Client) GetRollingRelease(ctx context.Context, projectID, teamID string) (RollingReleaseInfo, error) { url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, projectID, teamID) - tflog.Debug(ctx, "getting rolling-release configuration", map[string]any{ - "url": url, - "method": "GET", - "project_id": projectID, - "team_id": teamID, - }) - - d := RollingReleaseInfo{} - err := c.doRequest(clientRequest{ + resp, err := c.doRequestWithResponse(clientRequest{ ctx: ctx, method: "GET", url: url, - }, &d) + }) + + if err != nil { + return RollingReleaseInfo{}, fmt.Errorf("error getting rolling-release: %w", err) + } + + // Parse the response + var d RollingReleaseInfo + if err := json.Unmarshal([]byte(resp), &d); err != nil { + return RollingReleaseInfo{}, fmt.Errorf("error parsing response: %w", err) + } d.ProjectID = projectID d.TeamID = teamID - return d, err + + return d, nil } // UpdateRollingReleaseRequest defines the information that needs to be passed to Vercel in order to @@ -121,144 +143,131 @@ type UpdateRollingReleaseRequest struct { // UpdateRollingRelease will update an existing rolling release to the latest information. func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRollingReleaseRequest) (RollingReleaseInfo, error) { - // Validate the request - if err := request.RollingRelease.Validate(); err != nil { - return RollingReleaseInfo{}, fmt.Errorf("invalid rolling release configuration: %w", err) - } - - url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, request.ProjectID, request.TeamID) + // If we're enabling, we need to do it in steps + if request.RollingRelease.Enabled { + // First ensure it's disabled + disabledRequest := UpdateRollingReleaseRequest{ + RollingRelease: RollingRelease{ + Enabled: false, + AdvancementType: "", + Stages: []RollingReleaseStage{}, + }, + ProjectID: request.ProjectID, + TeamID: request.TeamID, + } - // Process stages to ensure final stage only has targetPercentage - stages := make([]map[string]any, len(request.RollingRelease.Stages)) - for i, stage := range request.RollingRelease.Stages { - if i == len(request.RollingRelease.Stages)-1 { - // Final stage should only have targetPercentage - stages[i] = map[string]any{ - "targetPercentage": stage.TargetPercentage, - } - } else { - // Other stages can have all properties - stageMap := map[string]any{ - "targetPercentage": stage.TargetPercentage, - "requireApproval": stage.RequireApproval, - } - // Only include duration if it's set - if stage.Duration != nil { - stageMap["duration"] = *stage.Duration - } - stages[i] = stageMap + _, err := c.doRequestWithResponse(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)), + }) + if err != nil { + return RollingReleaseInfo{}, fmt.Errorf("error disabling rolling release: %w", err) } - } - // Send just the rolling release configuration, not the whole request - payload := string(mustMarshal(map[string]any{ - "enabled": request.RollingRelease.Enabled, - "advancementType": request.RollingRelease.AdvancementType, - "stages": stages, - })) - - tflog.Debug(ctx, "updating rolling-release configuration", map[string]any{ - "url": url, - "method": "PATCH", - "project_id": request.ProjectID, - "team_id": request.TeamID, - "payload": payload, - "base_url": c.baseURL, - "enabled": request.RollingRelease.Enabled, - "advancement_type": request.RollingRelease.AdvancementType, - "stages_count": len(request.RollingRelease.Stages), - }) + // Wait a bit before proceeding + time.Sleep(2 * time.Second) - // Log each stage for debugging - for i, stage := range stages { - tflog.Debug(ctx, fmt.Sprintf("stage %d configuration", i), map[string]any{ - "stage": stage, + // Now validate the request + if err := request.RollingRelease.Validate(); err != nil { + return RollingReleaseInfo{}, fmt.Errorf("invalid rolling release configuration: %w", err) + } + + // Sort stages by target percentage + sort.Slice(request.RollingRelease.Stages, func(i, j int) bool { + return request.RollingRelease.Stages[i].TargetPercentage < request.RollingRelease.Stages[j].TargetPercentage }) - } - var d RollingReleaseInfo - resp, err := c.doRequestWithResponse(clientRequest{ - ctx: ctx, - method: "PATCH", - url: url, - body: payload, - }) + // First set up the stages + stagesRequest := map[string]any{ + "enabled": false, + "advancementType": request.RollingRelease.AdvancementType, + "stages": request.RollingRelease.Stages, + } - // Always log the raw response for debugging - tflog.Debug(ctx, "received raw response", map[string]any{ - "response": resp, - }) + _, err = c.doRequestWithResponse(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(stagesRequest)), + }) + if err != nil { + return RollingReleaseInfo{}, fmt.Errorf("error configuring stages: %w", err) + } - if err != nil { - // Try to parse error response - var errResp ErrorResponse - if resp != "" && json.Unmarshal([]byte(resp), &errResp) == nil { - tflog.Error(ctx, "error updating rolling-release", map[string]any{ - "error_code": errResp.Error.Code, - "error_message": errResp.Error.Message, - "url": url, - "payload": payload, - "response": resp, - }) - return d, fmt.Errorf("failed to update rolling release: %s - %s", errResp.Error.Code, errResp.Error.Message) + // Wait a bit before enabling + time.Sleep(2 * time.Second) + + // Finally enable it + enableRequest := map[string]any{ + "enabled": true, + "advancementType": request.RollingRelease.AdvancementType, + "stages": request.RollingRelease.Stages, } - tflog.Error(ctx, "error updating rolling-release", map[string]any{ - "error": err.Error(), - "url": url, - "payload": payload, - "response": resp, + resp, err := c.doRequestWithResponse(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)), }) - return d, fmt.Errorf("failed to update rolling release: %w", err) - } + if err != nil { + // Try to parse error response + var errResp ErrorResponse + if jsonErr := json.Unmarshal([]byte(resp), &errResp); jsonErr == nil && errResp.Error.Message != "" { + return RollingReleaseInfo{}, fmt.Errorf("error enabling rolling release: %s - %s", errResp.Error.Code, errResp.Error.Message) + } + return RollingReleaseInfo{}, fmt.Errorf("error enabling rolling release: %w", err) + } - // Return the request state since we know it's valid - result := RollingReleaseInfo{ - ProjectID: request.ProjectID, - TeamID: request.TeamID, - RollingRelease: RollingRelease{ - Enabled: request.RollingRelease.Enabled, - AdvancementType: request.RollingRelease.AdvancementType, - Stages: make([]RollingReleaseStage, len(request.RollingRelease.Stages)), - }, - } + // Parse the response + var result RollingReleaseInfo + if err := json.Unmarshal([]byte(resp), &result); err != nil { + return RollingReleaseInfo{}, fmt.Errorf("error parsing response: %w", err) + } + result.ProjectID = request.ProjectID + result.TeamID = request.TeamID + + return result, nil + } else { + // For disabling, just send the request as is + disabledRequest := UpdateRollingReleaseRequest{ + RollingRelease: RollingRelease{ + Enabled: false, + AdvancementType: "", + Stages: []RollingReleaseStage{}, + }, + ProjectID: request.ProjectID, + TeamID: request.TeamID, + } - // Copy stages, preserving the duration and requireApproval for non-final stages - for i, stage := range request.RollingRelease.Stages { - if i == len(request.RollingRelease.Stages)-1 { - // For the final stage, only include targetPercentage - result.RollingRelease.Stages[i] = RollingReleaseStage{ - TargetPercentage: stage.TargetPercentage, - // Do not include Duration or RequireApproval for final stage - } - } else { - // For other stages, include all properties - result.RollingRelease.Stages[i] = stage + resp, err := c.doRequestWithResponse(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)), + }) + if err != nil { + return RollingReleaseInfo{}, fmt.Errorf("error disabling rolling release: %w", err) } - } - tflog.Debug(ctx, "returning rolling release configuration", map[string]any{ - "project_id": result.ProjectID, - "team_id": result.TeamID, - "enabled": result.RollingRelease.Enabled, - "advancement_type": result.RollingRelease.AdvancementType, - "stages": result.RollingRelease.Stages, - }) + // Parse the response + var result RollingReleaseInfo + if err := json.Unmarshal([]byte(resp), &result); err != nil { + return RollingReleaseInfo{}, fmt.Errorf("error parsing response: %w", err) + } + result.ProjectID = request.ProjectID + result.TeamID = request.TeamID - return result, nil + return result, nil + } } // DeleteRollingRelease will delete the rolling release for a given project. func (c *Client) DeleteRollingRelease(ctx context.Context, projectID, teamID string) error { url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, projectID, teamID) - tflog.Debug(ctx, "deleting rolling-release configuration", map[string]any{ - "url": url, - "method": "DELETE", - "project_id": projectID, - "team_id": teamID, - }) - var d RollingReleaseInfo err := c.doRequest(clientRequest{ ctx: ctx, diff --git a/main.tf b/main.tf new file mode 100644 index 00000000..4be7e248 --- /dev/null +++ b/main.tf @@ -0,0 +1,32 @@ +terraform { + required_providers { + vercel = { + source = "vercel/vercel" + } + } +} + +resource "vercel_project_rolling_release" "example" { + project_id = "prj_9lRsbRoK8DCtxa4CmUu5rWfSaS86" + team_id = "team_4FWx5KQoszRi0ZmM9q9IBoKG" + rolling_release = { + enabled = true + advancement_type = "automatic" + stages = [ + { + duration = 1 + require_approval = false + target_percentage = 5 # Start with 5% + }, + { + duration = 1 + require_approval = false + target_percentage = 50 # Then 50% + }, + { + require_approval = false # No duration for last stage + target_percentage = 100 # Finally 100% + } + ] + } +} \ No newline at end of file diff --git a/vercel/data_source_project_rolling_release.go b/vercel/data_source_project_rolling_release.go index 4523c412..f648deef 100644 --- a/vercel/data_source_project_rolling_release.go +++ b/vercel/data_source_project_rolling_release.go @@ -140,16 +140,16 @@ func convertResponseToTFRollingReleaseDataSource(response client.RollingReleaseI result := TFRollingReleaseInfoDataSource{ RollingRelease: TFRollingReleaseDataSource{ Enabled: types.BoolValue(response.RollingRelease.Enabled), - AdvancementType: types.StringNull(), - Stages: make([]TFRollingReleaseStageDataSource, 0), + AdvancementType: types.StringValue(response.RollingRelease.AdvancementType), + Stages: convertStagesDataSource(response.RollingRelease.Stages), }, ProjectID: types.StringValue(response.ProjectID), TeamID: types.StringValue(response.TeamID), } - if response.RollingRelease.Enabled { - result.RollingRelease.AdvancementType = types.StringValue(response.RollingRelease.AdvancementType) - result.RollingRelease.Stages = convertStagesDataSource(response.RollingRelease.Stages) + if !response.RollingRelease.Enabled { + result.RollingRelease.AdvancementType = types.StringValue("") + result.RollingRelease.Stages = make([]TFRollingReleaseStageDataSource, 0) } return result diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go index f58a14bf..3622d9a3 100644 --- a/vercel/resource_project_rolling_release.go +++ b/vercel/resource_project_rolling_release.go @@ -3,9 +3,12 @@ package vercel import ( "context" "fmt" + "sort" + "time" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -62,15 +65,35 @@ func (v advancementTypeValidator) MarkdownDescription(ctx context.Context) strin } func (v advancementTypeValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { - if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { - return - } - value := req.ConfigValue.ValueString() - if value != "automatic" && value != "manual-approval" { + // 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( - "Invalid advancement_type", - fmt.Sprintf("advancement_type must be either 'automatic' or 'manual-approval', got: %s", value), + "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 + } } } @@ -116,7 +139,6 @@ func (r *projectRollingReleaseResource) Schema(ctx context.Context, _ resource.S "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, - Computed: true, }, "require_approval": schema.BoolAttribute{ MarkdownDescription: "Whether approval is required before advancing to the next stage.", @@ -176,46 +198,137 @@ func (e *TFRollingReleaseInfo) toUpdateRollingReleaseRequest() (client.UpdateRol var diags diag.Diagnostics if e.RollingRelease.Enabled.ValueBool() { - if !e.RollingRelease.AdvancementType.IsNull() { - advancementType = e.RollingRelease.AdvancementType.ValueString() - } else { - advancementType = "manual-approval" // Default to manual-approval if not specified + // When enabled, both advancement_type and stages are required + if e.RollingRelease.AdvancementType.IsNull() || e.RollingRelease.AdvancementType.IsUnknown() { + diags.AddError( + "Error creating rolling release", + "advancement_type is required when enabled is true", + ) + return client.UpdateRollingReleaseRequest{}, diags + } + advancementType = e.RollingRelease.AdvancementType.ValueString() + + if e.RollingRelease.Stages.IsNull() || e.RollingRelease.Stages.IsUnknown() { + diags.AddError( + "Error creating rolling release", + "stages are required when enabled is true", + ) + return client.UpdateRollingReleaseRequest{}, diags } // Convert stages from types.List to []client.RollingReleaseStage var tfStages []TFRollingReleaseStage - if !e.RollingRelease.Stages.IsNull() && !e.RollingRelease.Stages.IsUnknown() { - diags = e.RollingRelease.Stages.ElementsAs(context.Background(), &tfStages, false) - if diags.HasError() { + diags = e.RollingRelease.Stages.ElementsAs(context.Background(), &tfStages, false) + if diags.HasError() { + return client.UpdateRollingReleaseRequest{}, diags + } + + // Validate stages + if len(tfStages) < 2 || len(tfStages) > 10 { + diags.AddError( + "Error creating rolling release", + fmt.Sprintf("must have between 2 and 10 stages when enabled is true, got: %d", len(tfStages)), + ) + return client.UpdateRollingReleaseRequest{}, diags + } + + // Sort stages by target percentage to ensure correct order + sort.Slice(tfStages, func(i, j int) bool { + return tfStages[i].TargetPercentage.ValueInt64() < tfStages[j].TargetPercentage.ValueInt64() + }) + + // Validate stages are in ascending order and within bounds + prevPercentage := int64(0) + for i, stage := range tfStages { + percentage := stage.TargetPercentage.ValueInt64() + + // Validate percentage bounds + if percentage < 0 || percentage > 100 { + diags.AddError( + "Error creating rolling release", + fmt.Sprintf("stage %d: target_percentage must be between 0 and 100, got: %d", i, percentage), + ) return client.UpdateRollingReleaseRequest{}, diags } - stages = make([]client.RollingReleaseStage, len(tfStages)) - for i, stage := range tfStages { - // For automatic advancement, set a default duration if not provided - if advancementType == "automatic" { - var duration int = 60 // Default duration in minutes - if !stage.Duration.IsNull() { - duration = int(stage.Duration.ValueInt64()) - } - stages[i] = client.RollingReleaseStage{ - TargetPercentage: int(stage.TargetPercentage.ValueInt64()), - Duration: &duration, - RequireApproval: stage.RequireApproval.ValueBool(), + + // Validate ascending order + if percentage <= prevPercentage { + diags.AddError( + "Error creating rolling release", + fmt.Sprintf("stage %d: target_percentage must be greater than previous stage (%d), got: %d", i, prevPercentage, percentage), + ) + return client.UpdateRollingReleaseRequest{}, diags + } + prevPercentage = percentage + } + + // Validate last stage is 100% + lastStage := tfStages[len(tfStages)-1] + if lastStage.TargetPercentage.ValueInt64() != 100 { + diags.AddError( + "Error creating rolling release", + fmt.Sprintf("last stage must have target_percentage=100, got: %d", lastStage.TargetPercentage.ValueInt64()), + ) + 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()) + if duration < 1 || duration > 10000 { + diags.AddError( + "Error creating rolling release", + fmt.Sprintf("stage %d: duration must be between 1 and 10000 minutes for automatic advancement, got: %d", i, duration), + ) + return client.UpdateRollingReleaseRequest{}, diags + } + stages[i] = client.RollingReleaseStage{ + TargetPercentage: int(stage.TargetPercentage.ValueInt64()), + Duration: &duration, + RequireApproval: stage.RequireApproval.ValueBool(), + } } } else { - // For manual approval, omit duration field completely + // Last stage should not have duration stages[i] = client.RollingReleaseStage{ TargetPercentage: int(stage.TargetPercentage.ValueInt64()), RequireApproval: stage.RequireApproval.ValueBool(), } } + } else { + // For manual approval, omit duration field completely + stages[i] = client.RollingReleaseStage{ + TargetPercentage: int(stage.TargetPercentage.ValueInt64()), + RequireApproval: stage.RequireApproval.ValueBool(), + } } } } else { - // When disabled, don't send any stages to the API + // When disabled, don't send any stages or advancement type to the API stages = []client.RollingReleaseStage{} + advancementType = "" } + // Log the request for debugging + tflog.Debug(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(), @@ -227,89 +340,109 @@ func (e *TFRollingReleaseInfo) toUpdateRollingReleaseRequest() (client.UpdateRol }, diags } -func convertStages(stages []client.RollingReleaseStage, advancementType string, planStages []TFRollingReleaseStage, enabled bool, ctx context.Context) (types.List, diag.Diagnostics) { - // If disabled, always return plan stages to preserve state - if !enabled && len(planStages) > 0 { - elements := make([]attr.Value, len(planStages)) - for i, stage := range planStages { - // For disabled state, ensure duration is known - var duration types.Int64 - if stage.Duration.IsUnknown() { - duration = types.Int64Null() - } else { - duration = stage.Duration - } +func convertResponseToTFRollingRelease(response client.RollingReleaseInfo, plan *TFRollingReleaseInfo, ctx context.Context) (TFRollingReleaseInfo, diag.Diagnostics) { + var diags diag.Diagnostics - elements[i] = types.ObjectValueMust( - map[string]attr.Type{ - "target_percentage": types.Int64Type, - "duration": types.Int64Type, - "require_approval": types.BoolType, - }, - map[string]attr.Value{ - "target_percentage": stage.TargetPercentage, - "duration": duration, - "require_approval": stage.RequireApproval, - }, - ) + // If we have a plan and the response doesn't match what we expect, + // this is likely a race condition and we should preserve the plan values + if plan != nil && plan.RollingRelease.Enabled.ValueBool() { + // If the plan is enabled but response shows disabled or missing fields, + // this is likely a race condition + if !response.RollingRelease.Enabled || + response.RollingRelease.AdvancementType == "" || + len(response.RollingRelease.Stages) == 0 { + tflog.Debug(ctx, "detected race condition, preserving plan values", map[string]any{ + "plan_enabled": plan.RollingRelease.Enabled.ValueBool(), + "plan_advancement_type": plan.RollingRelease.AdvancementType.ValueString(), + "plan_stages_count": len(plan.RollingRelease.Stages.Elements()), + "response_enabled": response.RollingRelease.Enabled, + "response_advancement_type": response.RollingRelease.AdvancementType, + "response_stages_count": len(response.RollingRelease.Stages), + }) + return *plan, diags } - return types.ListValueFrom(ctx, types.ObjectType{ - AttrTypes: map[string]attr.Type{ - "target_percentage": types.Int64Type, - "duration": types.Int64Type, - "require_approval": types.BoolType, - }, - }, elements) } - // If no stages from API and no plan stages, return empty list - if len(stages) == 0 { - return types.ListValueFrom(ctx, types.ObjectType{ + result := TFRollingReleaseInfo{ + RollingRelease: TFRollingRelease{ + Enabled: types.BoolValue(response.RollingRelease.Enabled), + AdvancementType: types.StringValue(response.RollingRelease.AdvancementType), + }, + ProjectID: types.StringValue(response.ProjectID), + TeamID: types.StringValue(response.TeamID), + } + + // If disabled, return empty values + if !response.RollingRelease.Enabled { + 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 } - elements := make([]attr.Value, len(stages)) - for i, stage := range stages { - targetPercentage := types.Int64Value(int64(stage.TargetPercentage)) - requireApproval := types.BoolValue(stage.RequireApproval) - var duration types.Int64 + // 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() && !plan.RollingRelease.Stages.IsUnknown() { + var planStages []TFRollingReleaseStage + diags.Append(plan.RollingRelease.Stages.ElementsAs(ctx, &planStages, false)...) + if diags.HasError() { + return result, diags + } - // If we have plan stages, preserve the values but ensure they're known - if i < len(planStages) { - targetPercentage = planStages[i].TargetPercentage - requireApproval = planStages[i].RequireApproval + // 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 + } - // Handle duration based on advancement type - if advancementType == "automatic" { - if planStages[i].Duration.IsUnknown() { - // For unknown values, use API value or default - if stage.Duration != nil { - duration = types.Int64Value(int64(*stage.Duration)) - } else { - duration = types.Int64Value(60) // Default duration in minutes - } - } else { - duration = planStages[i].Duration - } - } else { - duration = types.Int64Null() // Manual approval doesn't use duration + // Try to preserve the order from the plan + orderedStages = make([]client.RollingReleaseStage, 0, len(response.RollingRelease.Stages)) + for _, planStage := range planStages { + if stage, ok := stageMap[int(planStage.TargetPercentage.ValueInt64())]; ok { + orderedStages = append(orderedStages, stage) + delete(stageMap, stage.TargetPercentage) } - } else { - // Only set duration for automatic advancement - if advancementType == "automatic" { + } + + // Add any remaining stages that weren't in the plan + for _, stage := range response.RollingRelease.Stages { + if _, ok := stageMap[stage.TargetPercentage]; ok { + orderedStages = append(orderedStages, stage) + } + } + } else { + orderedStages = response.RollingRelease.Stages + } + + // Convert stages from response + elements := make([]attr.Value, len(orderedStages)) + for i, stage := range orderedStages { + var duration types.Int64 + if response.RollingRelease.AdvancementType == "automatic" { + // For automatic advancement, duration is required except for the last stage + if i < len(orderedStages)-1 { if stage.Duration != nil { duration = types.Int64Value(int64(*stage.Duration)) } else { duration = types.Int64Value(60) // Default duration in minutes } } else { - duration = types.Int64Null() + duration = types.Int64Value(0) // Last stage doesn't need duration } + } else { + // For manual approval, duration is not used + duration = types.Int64Value(0) } elements[i] = types.ObjectValueMust( @@ -319,70 +452,38 @@ func convertStages(stages []client.RollingReleaseStage, advancementType string, "require_approval": types.BoolType, }, map[string]attr.Value{ - "target_percentage": targetPercentage, + "target_percentage": types.Int64Value(int64(stage.TargetPercentage)), "duration": duration, - "require_approval": requireApproval, + "require_approval": types.BoolValue(stage.RequireApproval), }, ) } - return types.ListValueFrom(ctx, types.ObjectType{ + stages, stagesDiags := types.ListValueFrom(ctx, types.ObjectType{ AttrTypes: map[string]attr.Type{ "target_percentage": types.Int64Type, "duration": types.Int64Type, "require_approval": types.BoolType, }, }, elements) -} - -func convertResponseToTFRollingRelease(response client.RollingReleaseInfo, plan *TFRollingReleaseInfo, ctx context.Context) (TFRollingReleaseInfo, diag.Diagnostics) { - var diags diag.Diagnostics - - result := TFRollingReleaseInfo{ - RollingRelease: TFRollingRelease{ - Enabled: types.BoolValue(response.RollingRelease.Enabled), - }, - ProjectID: types.StringValue(response.ProjectID), - TeamID: types.StringValue(response.TeamID), - } - - // Get plan stages if available - var planStages []TFRollingReleaseStage - if plan != nil && !plan.RollingRelease.Stages.IsNull() && !plan.RollingRelease.Stages.IsUnknown() { - diags.Append(plan.RollingRelease.Stages.ElementsAs(ctx, &planStages, false)...) - if diags.HasError() { - return result, diags - } - } - - if response.RollingRelease.Enabled { - result.RollingRelease.AdvancementType = types.StringValue(response.RollingRelease.AdvancementType) - } else { - result.RollingRelease.AdvancementType = types.StringNull() - } - - // Convert stages, passing enabled state to ensure proper preservation - stages, stagesDiags := convertStages( - response.RollingRelease.Stages, - response.RollingRelease.AdvancementType, - planStages, - response.RollingRelease.Enabled, - ctx, - ) diags.Append(stagesDiags...) if diags.HasError() { return result, diags } result.RollingRelease.Stages = stages + // Log the conversion result for debugging + tflog.Debug(ctx, "converted rolling release response", map[string]any{ + "enabled": result.RollingRelease.Enabled.ValueBool(), + "advancement_type": result.RollingRelease.AdvancementType.ValueString(), + "stages_count": len(elements), + }) + return result, diags } -// Create will create a new rolling release config on a Vercel project. -// This is called automatically by the provider when a new resource should be created. +// 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) { - tflog.Debug(ctx, "Starting rolling release creation") - var plan TFRollingReleaseInfo diags := req.Plan.Get(ctx, &plan) resp.Diagnostics.Append(diags...) @@ -390,62 +491,74 @@ func (r *projectRollingReleaseResource) Create(ctx context.Context, req resource return } - tflog.Debug(ctx, "Got plan from request", map[string]any{ - "project_id": plan.ProjectID.ValueString(), - "team_id": plan.TeamID.ValueString(), - "enabled": plan.RollingRelease.Enabled.ValueBool(), - "advancement_type": plan.RollingRelease.AdvancementType.ValueString(), - "stages": plan.RollingRelease.Stages, - }) - - _, err := r.client.GetProject(ctx, plan.ProjectID.ValueString(), plan.TeamID.ValueString()) - if client.NotFound(err) { - resp.Diagnostics.AddError( - "Error creating project rolling release", - "Could not find project, please make sure both the project_id and team_id match the project and team you wish to deploy to.", - ) - return - } - if err != nil { - resp.Diagnostics.AddError( - "Error creating project rolling release", - "Error reading project information, unexpected error: "+err.Error(), - ) + // Convert plan to client request + request, diags := plan.toUpdateRollingReleaseRequest() + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { return } - tflog.Debug(ctx, "Project exists, creating rolling release") + // Log the request for debugging + tflog.Debug(ctx, "creating rolling release", map[string]any{ + "enabled": request.RollingRelease.Enabled, + "advancement_type": request.RollingRelease.AdvancementType, + "stages": request.RollingRelease.Stages, + }) - updateRequest, diags := plan.toUpdateRollingReleaseRequest() - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return + // 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 + } + + // Wait a bit before enabling + time.Sleep(2 * time.Second) } - response, err := r.client.UpdateRollingRelease(ctx, updateRequest) + out, err := r.client.UpdateRollingRelease(ctx, request) if err != nil { resp.Diagnostics.AddError( "Error creating project rolling release", - "Could not create project rolling release, unexpected error: "+err.Error(), + fmt.Sprintf("Could not create project rolling release, unexpected error: %s", + err, + ), ) return } - result, diags := convertResponseToTFRollingRelease(response, &plan, ctx) + // Convert response to state + result, diags := convertResponseToTFRollingRelease(out, &plan, ctx) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } - // Log the values for debugging - tflog.Debug(ctx, "created project rolling release", map[string]any{ - "team_id": result.TeamID.ValueString(), - "project_id": result.ProjectID.ValueString(), + // 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() { @@ -453,7 +566,7 @@ func (r *projectRollingReleaseResource) Create(ctx context.Context, req resource } } -// Read will read an rolling release of a Vercel project by requesting it from the Vercel API, and will update terraform +// 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 TFRollingReleaseInfo @@ -478,51 +591,34 @@ func (r *projectRollingReleaseResource) Read(ctx context.Context, req resource.R return } - result, _ := convertResponseToTFRollingRelease(out, nil, ctx) - tflog.Info(ctx, "read project rolling release", map[string]any{ - "team_id": result.TeamID.ValueString(), - "project_id": result.ProjectID.ValueString(), + // 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, }) - diags = resp.State.Set(ctx, result) + result, diags := convertResponseToTFRollingRelease(out, &state, ctx) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } -} -// Delete deletes a Vercel project rolling release. -func (r *projectRollingReleaseResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var state TFRollingReleaseInfo - diags := req.State.Get(ctx, &state) + // 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 } - - err := r.client.DeleteRollingRelease(ctx, state.ProjectID.ValueString(), state.TeamID.ValueString()) - if client.NotFound(err) { - return - } - if err != nil { - resp.Diagnostics.AddError( - "Error deleting project rolling release", - fmt.Sprintf( - "Could not delete project rolling release %s, unexpected error: %s", - state.ProjectID.ValueString(), - err, - ), - ) - return - } - - tflog.Info(ctx, "deleted project rolling release", map[string]any{ - "team_id": state.TeamID.ValueString(), - "project_id": state.ProjectID.ValueString(), - }) } -// Update updates the project rolling release of a Vercel project state. +// 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 TFRollingReleaseInfo diags := req.Plan.Get(ctx, &plan) @@ -531,31 +627,101 @@ func (r *projectRollingReleaseResource) Update(ctx context.Context, req resource return } - updateRequest, diags := plan.toUpdateRollingReleaseRequest() + var state TFRollingReleaseInfo + 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 } - response, err := r.client.UpdateRollingRelease(ctx, updateRequest) + // 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 + } + + // Wait a bit before proceeding + time.Sleep(2 * time.Second) + } + + // 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 + } + + // Wait a bit before enabling + time.Sleep(2 * time.Second) + } + + out, err := r.client.UpdateRollingRelease(ctx, request) if err != nil { resp.Diagnostics.AddError( "Error updating project rolling release", - "Could not update project rolling release, unexpected error: "+err.Error(), + fmt.Sprintf("Could not update project rolling release, unexpected error: %s", + err, + ), ) return } - result, diags := convertResponseToTFRollingRelease(response, &plan, ctx) + // Convert response to state + result, diags := convertResponseToTFRollingRelease(out, &plan, ctx) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } - // Log the values for debugging - tflog.Debug(ctx, "updated project rolling release", map[string]any{ - "team_id": result.TeamID.ValueString(), - "project_id": result.ProjectID.ValueString(), + // 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, @@ -568,6 +734,38 @@ func (r *projectRollingReleaseResource) Update(ctx context.Context, req resource } } +// 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 TFRollingReleaseInfo + 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) { @@ -592,13 +790,14 @@ func (r *projectRollingReleaseResource) ImportState(ctx context.Context, req res return } - result, _ := convertResponseToTFRollingRelease(out, nil, ctx) + // For import, we don't have any state to preserve + result, diags := convertResponseToTFRollingRelease(out, nil, ctx) 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) + 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..a8ab023a --- /dev/null +++ b/vercel/resource_project_rolling_release_test.go @@ -0,0 +1,214 @@ +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{ + // First create the resource in a disabled state + { + Config: cfg(testAccProjectRollingReleasesConfigOff(nameSuffix, testGithubRepo(t))), + Check: resource.ComposeAggregateTestCheckFunc( + 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"), + ), + }, + // Then enable with initial configuration + { + Config: cfg(testAccProjectRollingReleasesConfig(nameSuffix, testGithubRepo(t))), + Check: resource.ComposeAggregateTestCheckFunc( + 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": "true", + "target_percentage": "100", + }), + ), + }, + // Then update to new configuration + { + Config: cfg(testAccProjectRollingReleasesConfigUpdate(nameSuffix, testGithubRepo(t))), + Check: resource.ComposeAggregateTestCheckFunc( + 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": "true", + "target_percentage": "100", + }), + ), + }, + // Finally disable again + { + Config: cfg(testAccProjectRollingReleasesConfigOff(nameSuffix, testGithubRepo(t))), + Check: resource.ComposeAggregateTestCheckFunc( + 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, githubRepo string) string { + return fmt.Sprintf(` +resource "vercel_project" "example" { + name = "test-acc-example-project-%s" + git_repository = { + type = "github" + repo = "%s" + } + enable_preview_feedback = false + skew_protection = "12 hours" +} + +resource "vercel_project_rolling_release" "example" { + project_id = vercel_project.example.id + team_id = vercel_project.example.team_id + depends_on = [vercel_project.example] + 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 + } + ] + } +} +`, nameSuffix, githubRepo) +} + +func testAccProjectRollingReleasesConfigUpdate(nameSuffix string, githubRepo string) string { + return fmt.Sprintf(` +resource "vercel_project" "example" { + name = "test-acc-example-project-%s" + git_repository = { + type = "github" + repo = "%s" + } + enable_preview_feedback = false + skew_protection = "12 hours" +} + +resource "vercel_project_rolling_release" "example" { + project_id = vercel_project.example.id + team_id = vercel_project.example.team_id + depends_on = [vercel_project.example] + 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 = 80 + }, + { + require_approval = true + target_percentage = 100 + } + ] + } +} +`, nameSuffix, githubRepo) +} + +func testAccProjectRollingReleasesConfigOff(nameSuffix string, githubRepo string) string { + return fmt.Sprintf(` +resource "vercel_project" "example" { + name = "test-acc-example-project-%s" + git_repository = { + type = "github" + repo = "%s" + } + enable_preview_feedback = false + skew_protection = "12 hours" +} + +resource "vercel_project_rolling_release" "example" { + project_id = vercel_project.example.id + team_id = vercel_project.example.team_id + depends_on = [vercel_project.example] + rolling_release = { + enabled = false + advancement_type = "" + stages = [] + } +} +`, nameSuffix, githubRepo) +} From 5792285d98d1972e516a7fca168110e9769ea1d5 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Thu, 12 Jun 2025 10:13:01 -0400 Subject: [PATCH 18/70] removes doRequestWithResponse --- client/project_rolling_release.go | 46 +++++++++----------------- client/request.go | 54 ------------------------------- 2 files changed, 15 insertions(+), 85 deletions(-) diff --git a/client/project_rolling_release.go b/client/project_rolling_release.go index be1ce90c..b7617665 100644 --- a/client/project_rolling_release.go +++ b/client/project_rolling_release.go @@ -2,7 +2,6 @@ package client import ( "context" - "encoding/json" "fmt" "sort" "time" @@ -112,21 +111,17 @@ type RollingReleaseInfo struct { func (c *Client) GetRollingRelease(ctx context.Context, projectID, teamID string) (RollingReleaseInfo, error) { url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, projectID, teamID) - resp, err := c.doRequestWithResponse(clientRequest{ + 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) } - // Parse the response - var d RollingReleaseInfo - if err := json.Unmarshal([]byte(resp), &d); err != nil { - return RollingReleaseInfo{}, fmt.Errorf("error parsing response: %w", err) - } d.ProjectID = projectID d.TeamID = teamID @@ -156,12 +151,12 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling TeamID: request.TeamID, } - _, err := c.doRequestWithResponse(clientRequest{ + 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)), - }) + }, nil) if err != nil { return RollingReleaseInfo{}, fmt.Errorf("error disabling rolling release: %w", err) } @@ -186,12 +181,12 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling "stages": request.RollingRelease.Stages, } - _, err = c.doRequestWithResponse(clientRequest{ + 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(stagesRequest)), - }) + }, nil) if err != nil { return RollingReleaseInfo{}, fmt.Errorf("error configuring stages: %w", err) } @@ -206,26 +201,18 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling "stages": request.RollingRelease.Stages, } - resp, err := c.doRequestWithResponse(clientRequest{ + 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 { - // Try to parse error response - var errResp ErrorResponse - if jsonErr := json.Unmarshal([]byte(resp), &errResp); jsonErr == nil && errResp.Error.Message != "" { - return RollingReleaseInfo{}, fmt.Errorf("error enabling rolling release: %s - %s", errResp.Error.Code, errResp.Error.Message) - } return RollingReleaseInfo{}, fmt.Errorf("error enabling rolling release: %w", err) } - // Parse the response - var result RollingReleaseInfo - if err := json.Unmarshal([]byte(resp), &result); err != nil { - return RollingReleaseInfo{}, fmt.Errorf("error parsing response: %w", err) - } result.ProjectID = request.ProjectID result.TeamID = request.TeamID @@ -242,21 +229,18 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling TeamID: request.TeamID, } - resp, err := c.doRequestWithResponse(clientRequest{ + 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) } - // Parse the response - var result RollingReleaseInfo - if err := json.Unmarshal([]byte(resp), &result); err != nil { - return RollingReleaseInfo{}, fmt.Errorf("error parsing response: %w", err) - } result.ProjectID = request.ProjectID result.TeamID = request.TeamID diff --git a/client/request.go b/client/request.go index 798cb78b..75a58720 100644 --- a/client/request.go +++ b/client/request.go @@ -166,57 +166,3 @@ func (c *Client) _doRequest(req *http.Request, v any, errorOnNoContent bool) err return nil } - -// doRequestWithResponse is similar to doRequest but returns the raw response body as a string -func (c *Client) doRequestWithResponse(req clientRequest) (string, error) { - r, err := req.toHTTPRequest() - if err != nil { - return "", err - } - - r.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.token)) - resp, err := c.http().Do(r) - if err != nil { - return "", fmt.Errorf("error doing http request: %w", err) - } - - defer resp.Body.Close() - responseBody, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("error reading response body: %w", err) - } - - if resp.StatusCode >= 300 { - var errorResponse APIError - if string(responseBody) == "" { - errorResponse.StatusCode = resp.StatusCode - return string(responseBody), errorResponse - } - err = json.Unmarshal(responseBody, &struct { - Error *APIError `json:"error"` - }{ - Error: &errorResponse, - }) - if errorResponse.Code == "" && errorResponse.Message == "" { - return string(responseBody), fmt.Errorf("error performing API request: %d %s", resp.StatusCode, string(responseBody)) - } - if err != nil { - return string(responseBody), fmt.Errorf("error unmarshaling response for status code %d: %w: %s", resp.StatusCode, err, string(responseBody)) - } - errorResponse.StatusCode = resp.StatusCode - errorResponse.RawMessage = responseBody - errorResponse.retryAfter = 1000 // set a sensible default for retrying. This is in milliseconds. - if resp.StatusCode == 429 { - retryAfterRaw := resp.Header.Get("Retry-After") - if retryAfterRaw != "" { - retryAfter, err := strconv.Atoi(retryAfterRaw) - if err == nil && retryAfter > 0 { - errorResponse.retryAfter = retryAfter - } - } - } - return string(responseBody), errorResponse - } - - return string(responseBody), nil -} From 0b9b0d76dfe093be55d73715bdd61f75f32470b5 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Thu, 12 Jun 2025 10:37:32 -0400 Subject: [PATCH 19/70] review --- client/project_rolling_release.go | 30 ++++---- vercel/data_source_project_rolling_release.go | 9 +-- vercel/resource_project_rolling_release.go | 75 +++---------------- .../resource_project_rolling_release_test.go | 3 - vercel/validator_advancement_type.go | 54 +++++++++++++ 5 files changed, 81 insertions(+), 90 deletions(-) create mode 100644 vercel/validator_advancement_type.go diff --git a/client/project_rolling_release.go b/client/project_rolling_release.go index b7617665..86e2532d 100644 --- a/client/project_rolling_release.go +++ b/client/project_rolling_release.go @@ -109,7 +109,8 @@ type RollingReleaseInfo struct { // GetRollingRelease returns the rolling release for a given project. func (c *Client) GetRollingRelease(ctx context.Context, projectID, teamID string) (RollingReleaseInfo, error) { - url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, projectID, teamID) + 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{ @@ -123,7 +124,7 @@ func (c *Client) GetRollingRelease(ctx context.Context, projectID, teamID string } d.ProjectID = projectID - d.TeamID = teamID + d.TeamID = teamId return d, nil } @@ -138,6 +139,7 @@ type UpdateRollingReleaseRequest struct { // UpdateRollingRelease will update an existing rolling release to the latest information. func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRollingReleaseRequest) (RollingReleaseInfo, error) { + teamId := c.TeamID(request.TeamID) // If we're enabling, we need to do it in steps if request.RollingRelease.Enabled { // First ensure it's disabled @@ -147,14 +149,12 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling AdvancementType: "", Stages: []RollingReleaseStage{}, }, - ProjectID: request.ProjectID, - TeamID: request.TeamID, } 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), + url: fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, request.ProjectID, teamId), body: string(mustMarshal(disabledRequest.RollingRelease)), }, nil) if err != nil { @@ -184,7 +184,7 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling 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), + url: fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, request.ProjectID, teamId), body: string(mustMarshal(stagesRequest)), }, nil) if err != nil { @@ -206,7 +206,7 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling 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), + url: fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, request.ProjectID, teamId), body: string(mustMarshal(enableRequest)), }, &result) if err != nil { @@ -214,7 +214,7 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling } result.ProjectID = request.ProjectID - result.TeamID = request.TeamID + result.TeamID = teamId return result, nil } else { @@ -225,8 +225,6 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling AdvancementType: "", Stages: []RollingReleaseStage{}, }, - ProjectID: request.ProjectID, - TeamID: request.TeamID, } var result RollingReleaseInfo @@ -234,7 +232,7 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling 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), + url: fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, request.ProjectID, teamId), body: string(mustMarshal(disabledRequest.RollingRelease)), }, &result) if err != nil { @@ -242,7 +240,7 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling } result.ProjectID = request.ProjectID - result.TeamID = request.TeamID + result.TeamID = teamId return result, nil } @@ -250,15 +248,13 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling // DeleteRollingRelease will delete the rolling release for a given project. func (c *Client) DeleteRollingRelease(ctx context.Context, projectID, teamID string) error { - url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, projectID, teamID) + 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: "DELETE", url: url, - }, &d) - d.ProjectID = projectID - d.TeamID = teamID + }, nil) return err } diff --git a/vercel/data_source_project_rolling_release.go b/vercel/data_source_project_rolling_release.go index f648deef..b655aa99 100644 --- a/vercel/data_source_project_rolling_release.go +++ b/vercel/data_source_project_rolling_release.go @@ -54,8 +54,9 @@ func (d *projectRollingReleaseDataSource) Schema(ctx context.Context, _ datasour Required: true, }, "team_id": schema.StringAttribute{ - MarkdownDescription: "The ID of the team the project exists in.", - Required: true, + Optional: true, + Computed: true, + Description: "The ID of the Vercel team.", }, "rolling_release": schema.SingleNestedAttribute{ MarkdownDescription: "The rolling release configuration.", @@ -120,11 +121,9 @@ func convertStagesDataSource(stages []client.RollingReleaseStage) []TFRollingRel result := make([]TFRollingReleaseStageDataSource, len(stages)) for i, stage := range stages { - var duration types.Int64 + duration := types.Int64Null() if stage.Duration != nil { duration = types.Int64Value(int64(*stage.Duration)) - } else { - duration = types.Int64Null() } result[i] = TFRollingReleaseStageDataSource{ diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go index 3622d9a3..0c6c59a3 100644 --- a/vercel/resource_project_rolling_release.go +++ b/vercel/resource_project_rolling_release.go @@ -6,11 +6,13 @@ import ( "sort" "time" + "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/path" "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" @@ -53,50 +55,6 @@ func (r *projectRollingReleaseResource) Configure(ctx context.Context, req resou r.client = client } -// 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 - } - } -} - // 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{ @@ -107,8 +65,10 @@ func (r *projectRollingReleaseResource) Schema(ctx context.Context, _ resource.S Required: true, }, "team_id": schema.StringAttribute{ - MarkdownDescription: "The ID of the team the project exists in.", - Required: true, + 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.", @@ -135,6 +95,9 @@ func (r *projectRollingReleaseResource) Schema(ctx context.Context, _ resource.S "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 all stages except the final stage when using automatic advancement.", @@ -174,24 +137,6 @@ type TFRollingReleaseInfo struct { TeamID types.String `tfsdk:"team_id"` } -type RollingReleaseStage struct { - TargetPercentage int `json:"targetPercentage"` - Duration *int `json:"duration,omitempty"` - RequireApproval bool `json:"requireApproval"` -} - -type RollingRelease struct { - Enabled bool `json:"enabled"` - AdvancementType string `json:"advancementType"` - Stages []RollingReleaseStage `json:"stages"` -} - -type UpdateRollingReleaseRequest struct { - RollingRelease RollingRelease `json:"rollingRelease"` - ProjectID string `json:"-"` - TeamID string `json:"-"` -} - func (e *TFRollingReleaseInfo) toUpdateRollingReleaseRequest() (client.UpdateRollingReleaseRequest, diag.Diagnostics) { var stages []client.RollingReleaseStage var advancementType string diff --git a/vercel/resource_project_rolling_release_test.go b/vercel/resource_project_rolling_release_test.go index a8ab023a..f4c9270a 100644 --- a/vercel/resource_project_rolling_release_test.go +++ b/vercel/resource_project_rolling_release_test.go @@ -122,7 +122,6 @@ resource "vercel_project" "example" { resource "vercel_project_rolling_release" "example" { project_id = vercel_project.example.id - team_id = vercel_project.example.team_id depends_on = [vercel_project.example] rolling_release = { enabled = true @@ -160,7 +159,6 @@ resource "vercel_project" "example" { resource "vercel_project_rolling_release" "example" { project_id = vercel_project.example.id - team_id = vercel_project.example.team_id depends_on = [vercel_project.example] rolling_release = { enabled = true @@ -202,7 +200,6 @@ resource "vercel_project" "example" { resource "vercel_project_rolling_release" "example" { project_id = vercel_project.example.id - team_id = vercel_project.example.team_id depends_on = [vercel_project.example] rolling_release = { enabled = false 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 + } + } +} From 3647bd7db07284dd77177e95e4d7d618bca55054 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Thu, 12 Jun 2025 10:47:07 -0400 Subject: [PATCH 20/70] more review --- client/project_rolling_release.go | 85 ------------------- .../resource.tf | 27 ++++++ main.tf | 32 ------- vercel/resource_project_rolling_release.go | 4 + .../resource_project_rolling_release_test.go | 35 +++----- 5 files changed, 41 insertions(+), 142 deletions(-) create mode 100644 examples/resources/vercel_project_rolling_release/resource.tf delete mode 100644 main.tf diff --git a/client/project_rolling_release.go b/client/project_rolling_release.go index 86e2532d..ff80dae8 100644 --- a/client/project_rolling_release.go +++ b/client/project_rolling_release.go @@ -21,86 +21,6 @@ type RollingRelease struct { Stages []RollingReleaseStage `json:"stages"` // Required when enabled=true: 2-10 stages } -// ErrorResponse represents the error response from the Vercel API -type ErrorResponse struct { - Error struct { - Code string `json:"code"` - Message string `json:"message"` - } `json:"error"` -} - -// Validate checks if the rolling release configuration is valid according to API requirements -func (r *RollingRelease) Validate() error { - if !r.Enabled { - return nil // No validation needed when disabled - } - - // Validate advancement type - if r.AdvancementType == "" { - return fmt.Errorf("advancement_type is required when enabled is true") - } - if r.AdvancementType != "automatic" && r.AdvancementType != "manual-approval" { - return fmt.Errorf("advancement_type must be 'automatic' or 'manual-approval' when enabled is true, got: %s", r.AdvancementType) - } - - // Validate stages - if len(r.Stages) == 0 { - return fmt.Errorf("stages are required when enabled is true") - } - if len(r.Stages) < 2 || len(r.Stages) > 10 { - return fmt.Errorf("must have between 2 and 10 stages when enabled is true, got: %d", len(r.Stages)) - } - - // Sort stages by target percentage to ensure correct order - sort.Slice(r.Stages, func(i, j int) bool { - return r.Stages[i].TargetPercentage < r.Stages[j].TargetPercentage - }) - - // Validate stages are in ascending order and within bounds - prevPercentage := 0 - for i, stage := range r.Stages { - // Validate percentage bounds - if stage.TargetPercentage < 0 || stage.TargetPercentage > 100 { - return fmt.Errorf("stage %d: target_percentage must be between 0 and 100, got: %d", i, stage.TargetPercentage) - } - - // Validate ascending order - if stage.TargetPercentage <= prevPercentage { - return fmt.Errorf("stage %d: target_percentage must be greater than previous stage (%d), got: %d", i, prevPercentage, stage.TargetPercentage) - } - prevPercentage = stage.TargetPercentage - - // Validate duration for automatic advancement - if r.AdvancementType == "automatic" { - if i < len(r.Stages)-1 { // All stages except last need duration - if stage.Duration == nil { - return fmt.Errorf("stage %d: duration is required for automatic advancement (except for the last stage)", i) - } - if *stage.Duration < 1 || *stage.Duration > 10000 { - return fmt.Errorf("stage %d: duration must be between 1 and 10000 minutes for automatic advancement, got: %d", i, *stage.Duration) - } - } else { // Last stage should not have duration - if stage.Duration != nil { - return fmt.Errorf("stage %d: last stage should not have duration for automatic advancement", i) - } - } - } else { - // For manual approval, no stages should have duration - if stage.Duration != nil { - return fmt.Errorf("stage %d: duration should not be set for manual-approval advancement type", i) - } - } - } - - // Validate last stage is 100% - lastStage := r.Stages[len(r.Stages)-1] - if lastStage.TargetPercentage != 100 { - return fmt.Errorf("last stage must have target_percentage=100, got: %d", lastStage.TargetPercentage) - } - - return nil -} - type RollingReleaseInfo struct { RollingRelease RollingRelease `json:"rollingRelease"` ProjectID string `json:"projectId"` @@ -164,11 +84,6 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling // Wait a bit before proceeding time.Sleep(2 * time.Second) - // Now validate the request - if err := request.RollingRelease.Validate(); err != nil { - return RollingReleaseInfo{}, fmt.Errorf("invalid rolling release configuration: %w", err) - } - // Sort stages by target percentage sort.Slice(request.RollingRelease.Stages, func(i, j int) bool { return request.RollingRelease.Stages[i].TargetPercentage < request.RollingRelease.Stages[j].TargetPercentage 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..73605256 --- /dev/null +++ b/examples/resources/vercel_project_rolling_release/resource.tf @@ -0,0 +1,27 @@ +resource "vercel_project" "example" { + name = "test-acc-example-project-%s" + skew_protection = "12 hours" +} + +resource "vercel_project_rolling_release" "example" { + project_id = vercel_project.example.id + depends_on = [vercel_project.example] + 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 + } + ] + } +} \ No newline at end of file diff --git a/main.tf b/main.tf deleted file mode 100644 index 4be7e248..00000000 --- a/main.tf +++ /dev/null @@ -1,32 +0,0 @@ -terraform { - required_providers { - vercel = { - source = "vercel/vercel" - } - } -} - -resource "vercel_project_rolling_release" "example" { - project_id = "prj_9lRsbRoK8DCtxa4CmUu5rWfSaS86" - team_id = "team_4FWx5KQoszRi0ZmM9q9IBoKG" - rolling_release = { - enabled = true - advancement_type = "automatic" - stages = [ - { - duration = 1 - require_approval = false - target_percentage = 5 # Start with 5% - }, - { - duration = 1 - require_approval = false - target_percentage = 50 # Then 50% - }, - { - require_approval = false # No duration for last stage - target_percentage = 100 # Finally 100% - } - ] - } -} \ No newline at end of file diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go index 0c6c59a3..4e098806 100644 --- a/vercel/resource_project_rolling_release.go +++ b/vercel/resource_project_rolling_release.go @@ -737,6 +737,10 @@ func (r *projectRollingReleaseResource) ImportState(ctx context.Context, req res // For import, we don't have any state to preserve result, diags := convertResponseToTFRollingRelease(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(), diff --git a/vercel/resource_project_rolling_release_test.go b/vercel/resource_project_rolling_release_test.go index f4c9270a..ae8f8155 100644 --- a/vercel/resource_project_rolling_release_test.go +++ b/vercel/resource_project_rolling_release_test.go @@ -38,7 +38,7 @@ func TestAcc_ProjectRollingRelease(t *testing.T) { Steps: []resource.TestStep{ // First create the resource in a disabled state { - Config: cfg(testAccProjectRollingReleasesConfigOff(nameSuffix, testGithubRepo(t))), + Config: cfg(testAccProjectRollingReleasesConfigOff(nameSuffix)), Check: resource.ComposeAggregateTestCheckFunc( testAccProjectRollingReleaseExists(testClient(t), resourceName, testTeam(t)), resource.TestCheckResourceAttr(resourceName, "rolling_release.enabled", "false"), @@ -48,7 +48,7 @@ func TestAcc_ProjectRollingRelease(t *testing.T) { }, // Then enable with initial configuration { - Config: cfg(testAccProjectRollingReleasesConfig(nameSuffix, testGithubRepo(t))), + Config: cfg(testAccProjectRollingReleasesConfig(nameSuffix)), Check: resource.ComposeAggregateTestCheckFunc( testAccProjectRollingReleaseExists(testClient(t), resourceName, testTeam(t)), resource.TestCheckResourceAttr(resourceName, "rolling_release.enabled", "true"), @@ -70,7 +70,7 @@ func TestAcc_ProjectRollingRelease(t *testing.T) { }, // Then update to new configuration { - Config: cfg(testAccProjectRollingReleasesConfigUpdate(nameSuffix, testGithubRepo(t))), + Config: cfg(testAccProjectRollingReleasesConfigUpdate(nameSuffix)), Check: resource.ComposeAggregateTestCheckFunc( testAccProjectRollingReleaseExists(testClient(t), resourceName, testTeam(t)), resource.TestCheckResourceAttr(resourceName, "rolling_release.enabled", "true"), @@ -96,7 +96,7 @@ func TestAcc_ProjectRollingRelease(t *testing.T) { }, // Finally disable again { - Config: cfg(testAccProjectRollingReleasesConfigOff(nameSuffix, testGithubRepo(t))), + Config: cfg(testAccProjectRollingReleasesConfigOff(nameSuffix)), Check: resource.ComposeAggregateTestCheckFunc( testAccProjectRollingReleaseExists(testClient(t), resourceName, testTeam(t)), resource.TestCheckResourceAttr(resourceName, "rolling_release.enabled", "false"), @@ -108,15 +108,10 @@ func TestAcc_ProjectRollingRelease(t *testing.T) { }) } -func testAccProjectRollingReleasesConfig(nameSuffix string, githubRepo string) string { +func testAccProjectRollingReleasesConfig(nameSuffix string) string { return fmt.Sprintf(` resource "vercel_project" "example" { name = "test-acc-example-project-%s" - git_repository = { - type = "github" - repo = "%s" - } - enable_preview_feedback = false skew_protection = "12 hours" } @@ -142,18 +137,13 @@ resource "vercel_project_rolling_release" "example" { ] } } -`, nameSuffix, githubRepo) +`, nameSuffix) } -func testAccProjectRollingReleasesConfigUpdate(nameSuffix string, githubRepo string) string { +func testAccProjectRollingReleasesConfigUpdate(nameSuffix string) string { return fmt.Sprintf(` resource "vercel_project" "example" { name = "test-acc-example-project-%s" - git_repository = { - type = "github" - repo = "%s" - } - enable_preview_feedback = false skew_protection = "12 hours" } @@ -183,18 +173,13 @@ resource "vercel_project_rolling_release" "example" { ] } } -`, nameSuffix, githubRepo) +`, nameSuffix) } -func testAccProjectRollingReleasesConfigOff(nameSuffix string, githubRepo string) string { +func testAccProjectRollingReleasesConfigOff(nameSuffix string) string { return fmt.Sprintf(` resource "vercel_project" "example" { name = "test-acc-example-project-%s" - git_repository = { - type = "github" - repo = "%s" - } - enable_preview_feedback = false skew_protection = "12 hours" } @@ -207,5 +192,5 @@ resource "vercel_project_rolling_release" "example" { stages = [] } } -`, nameSuffix, githubRepo) +`, nameSuffix) } From 10fcb7646feaa6d53713b5eb4b9f1da0df3e39a9 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Thu, 12 Jun 2025 10:59:36 -0400 Subject: [PATCH 21/70] review --- docs/data-sources/project_rolling_release.md | 10 ++++ docs/resources/project_rolling_release.md | 32 +++++++++++- .../data-source.tf | 7 +++ vercel/data_source_project_rolling_release.go | 2 +- ...ata_source_project_rolling_release_test.go | 52 +++++++++++++++++++ 5 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 examples/data-sources/vercel_project_rolling_release/data-source.tf create mode 100644 vercel/data_source_project_rolling_release_test.go diff --git a/docs/data-sources/project_rolling_release.md b/docs/data-sources/project_rolling_release.md index 971f58c2..aabcacbd 100644 --- a/docs/data-sources/project_rolling_release.md +++ b/docs/data-sources/project_rolling_release.md @@ -10,7 +10,17 @@ description: |- 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 diff --git a/docs/resources/project_rolling_release.md b/docs/resources/project_rolling_release.md index 17690c45..a9b67b96 100644 --- a/docs/resources/project_rolling_release.md +++ b/docs/resources/project_rolling_release.md @@ -10,7 +10,37 @@ description: |- Manages rolling release configuration for a Vercel project. - +## Example Usage + +```terraform +resource "vercel_project" "example" { + name = "test-acc-example-project-%s" + skew_protection = "12 hours" +} + +resource "vercel_project_rolling_release" "example" { + project_id = vercel_project.example.id + depends_on = [vercel_project.example] + 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 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..11b8f357 --- /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 +} \ No newline at end of file diff --git a/vercel/data_source_project_rolling_release.go b/vercel/data_source_project_rolling_release.go index b655aa99..4f4de53f 100644 --- a/vercel/data_source_project_rolling_release.go +++ b/vercel/data_source_project_rolling_release.go @@ -116,7 +116,7 @@ type TFRollingReleaseInfoDataSource struct { func convertStagesDataSource(stages []client.RollingReleaseStage) []TFRollingReleaseStageDataSource { if len(stages) == 0 { - return make([]TFRollingReleaseStageDataSource, 0) + return nil } result := make([]TFRollingReleaseStageDataSource, len(stages)) 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..e1703db2 --- /dev/null +++ b/vercel/data_source_project_rolling_release_test.go @@ -0,0 +1,52 @@ +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(testAccProjectRollingReleaseDataSourceConfig(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 testAccProjectRollingReleaseDataSourceConfig(projectName string) string { + return fmt.Sprintf(` +resource "vercel_project" "example" { + name = "test-acc-example-project-%[1]s" +} + +resource "vercel_project_rolling_release" "example" { + project_id = vercel_project.example.id + depends_on = [vercel_project.example] + rolling_release = { + enabled = false + advancement_type = "" + stages = [] + } +} + +data "vercel_project_rolling_release" "example" { + project_id = vercel_project_rolling_release.example.project_id +} +`, projectName) +} From 5031a0d65638af0d83357c95451033498be6668d Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Thu, 12 Jun 2025 18:57:05 -0400 Subject: [PATCH 22/70] remove sorting --- client/project_rolling_release.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/client/project_rolling_release.go b/client/project_rolling_release.go index ff80dae8..6a81bee0 100644 --- a/client/project_rolling_release.go +++ b/client/project_rolling_release.go @@ -3,7 +3,6 @@ package client import ( "context" "fmt" - "sort" "time" ) @@ -84,11 +83,6 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling // Wait a bit before proceeding time.Sleep(2 * time.Second) - // Sort stages by target percentage - sort.Slice(request.RollingRelease.Stages, func(i, j int) bool { - return request.RollingRelease.Stages[i].TargetPercentage < request.RollingRelease.Stages[j].TargetPercentage - }) - // First set up the stages stagesRequest := map[string]any{ "enabled": false, From b9dd027792515f09998ec0b714c8a926881021f2 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Thu, 12 Jun 2025 18:58:34 -0400 Subject: [PATCH 23/70] add import.sh --- .../resources/vercel_project_rolling_release/import.sh | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 examples/resources/vercel_project_rolling_release/import.sh diff --git a/examples/resources/vercel_project_rolling_release/import.sh b/examples/resources/vercel_project_rolling_release/import.sh new file mode 100644 index 00000000..b38a9b2a --- /dev/null +++ b/examples/resources/vercel_project_rolling_release/import.sh @@ -0,0 +1,9 @@ +# If importing into a personal account, or with a team configured on +# the provider, simply use the project ID. +# - project_id can be found in the project `settings` tab in the Vercel UI. +terraform import vercel_project.example prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Alternatively, you can import via the team_id and project_id. +# - team_id can be found in the team `settings` tab in the Vercel UI. +# - project_id can be found in the project `settings` tab in the Vercel UI. +terraform import vercel_project.example team_xxxxxxxxxxxxxxxxxxxxxxxx/prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx From 7fea6aac26bed83c9c063247127d98a8a453f4fa Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Thu, 12 Jun 2025 18:58:59 -0400 Subject: [PATCH 24/70] eof --- .../data-sources/vercel_project_rolling_release/data-source.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/data-sources/vercel_project_rolling_release/data-source.tf b/examples/data-sources/vercel_project_rolling_release/data-source.tf index 11b8f357..e3b96582 100644 --- a/examples/data-sources/vercel_project_rolling_release/data-source.tf +++ b/examples/data-sources/vercel_project_rolling_release/data-source.tf @@ -4,4 +4,4 @@ data "vercel_project" "example" { data "vercel_project_rolling_release" "example" { project_id = data.vercel_project_rolling_release.example.id -} \ No newline at end of file +} From 935d69d5649dc8fe088fcf551bce4cf3d5bb907f Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Thu, 12 Jun 2025 19:01:05 -0400 Subject: [PATCH 25/70] fix name --- examples/resources/vercel_project_rolling_release/resource.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/resources/vercel_project_rolling_release/resource.tf b/examples/resources/vercel_project_rolling_release/resource.tf index 73605256..71ff1b34 100644 --- a/examples/resources/vercel_project_rolling_release/resource.tf +++ b/examples/resources/vercel_project_rolling_release/resource.tf @@ -1,5 +1,5 @@ resource "vercel_project" "example" { - name = "test-acc-example-project-%s" + name = "example-project" skew_protection = "12 hours" } From 206a8d1904029efe67c5015ffb6673b0c572dc40 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Thu, 12 Jun 2025 19:01:32 -0400 Subject: [PATCH 26/70] eof --- examples/resources/vercel_project_rolling_release/resource.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/resources/vercel_project_rolling_release/resource.tf b/examples/resources/vercel_project_rolling_release/resource.tf index 71ff1b34..cb2c2654 100644 --- a/examples/resources/vercel_project_rolling_release/resource.tf +++ b/examples/resources/vercel_project_rolling_release/resource.tf @@ -24,4 +24,4 @@ resource "vercel_project_rolling_release" "example" { } ] } -} \ No newline at end of file +} From 6b272c611970e9c44eb5ad9352e43d33ccd757fc Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Thu, 12 Jun 2025 19:02:39 -0400 Subject: [PATCH 27/70] update nil to empty array --- vercel/data_source_project_rolling_release.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vercel/data_source_project_rolling_release.go b/vercel/data_source_project_rolling_release.go index 4f4de53f..e0f12f70 100644 --- a/vercel/data_source_project_rolling_release.go +++ b/vercel/data_source_project_rolling_release.go @@ -116,7 +116,7 @@ type TFRollingReleaseInfoDataSource struct { func convertStagesDataSource(stages []client.RollingReleaseStage) []TFRollingReleaseStageDataSource { if len(stages) == 0 { - return nil + return []TFRollingReleaseStageDataSource{} } result := make([]TFRollingReleaseStageDataSource, len(stages)) From f29fde57e94a107acc08b5050ae55d59327f0144 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Thu, 12 Jun 2025 19:03:29 -0400 Subject: [PATCH 28/70] Update vercel/data_source_project_rolling_release.go Co-authored-by: Douglas Harcourt Parsons --- vercel/data_source_project_rolling_release.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vercel/data_source_project_rolling_release.go b/vercel/data_source_project_rolling_release.go index e0f12f70..c4a8e52b 100644 --- a/vercel/data_source_project_rolling_release.go +++ b/vercel/data_source_project_rolling_release.go @@ -148,7 +148,7 @@ func convertResponseToTFRollingReleaseDataSource(response client.RollingReleaseI if !response.RollingRelease.Enabled { result.RollingRelease.AdvancementType = types.StringValue("") - result.RollingRelease.Stages = make([]TFRollingReleaseStageDataSource, 0) + result.RollingRelease.Stages = []TFRollingReleaseStageDataSource{} } return result From 0efd0a6bedfbb67f8d913e65019287e31411f05d Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Thu, 12 Jun 2025 19:04:58 -0400 Subject: [PATCH 29/70] update TF prefix names --- vercel/data_source_project_rolling_release.go | 38 ++++++++-------- vercel/resource_project_rolling_release.go | 44 +++++++++---------- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/vercel/data_source_project_rolling_release.go b/vercel/data_source_project_rolling_release.go index c4a8e52b..9e82f96f 100644 --- a/vercel/data_source_project_rolling_release.go +++ b/vercel/data_source_project_rolling_release.go @@ -96,37 +96,37 @@ func (d *projectRollingReleaseDataSource) Schema(ctx context.Context, _ datasour } } -type TFRollingReleaseStageDataSource struct { +type RollingReleaseStageDataSource struct { TargetPercentage types.Int64 `tfsdk:"target_percentage"` Duration types.Int64 `tfsdk:"duration"` RequireApproval types.Bool `tfsdk:"require_approval"` } -type TFRollingReleaseDataSource struct { - Enabled types.Bool `tfsdk:"enabled"` - AdvancementType types.String `tfsdk:"advancement_type"` - Stages []TFRollingReleaseStageDataSource `tfsdk:"stages"` +type RollingReleaseDataSource struct { + Enabled types.Bool `tfsdk:"enabled"` + AdvancementType types.String `tfsdk:"advancement_type"` + Stages []RollingReleaseStageDataSource `tfsdk:"stages"` } -type TFRollingReleaseInfoDataSource struct { - RollingRelease TFRollingReleaseDataSource `tfsdk:"rolling_release"` - ProjectID types.String `tfsdk:"project_id"` - TeamID types.String `tfsdk:"team_id"` +type RollingReleaseInfoDataSource struct { + RollingRelease RollingReleaseDataSource `tfsdk:"rolling_release"` + ProjectID types.String `tfsdk:"project_id"` + TeamID types.String `tfsdk:"team_id"` } -func convertStagesDataSource(stages []client.RollingReleaseStage) []TFRollingReleaseStageDataSource { +func convertStagesDataSource(stages []client.RollingReleaseStage) []RollingReleaseStageDataSource { if len(stages) == 0 { - return []TFRollingReleaseStageDataSource{} + return []RollingReleaseStageDataSource{} } - result := make([]TFRollingReleaseStageDataSource, len(stages)) + 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] = TFRollingReleaseStageDataSource{ + result[i] = RollingReleaseStageDataSource{ TargetPercentage: types.Int64Value(int64(stage.TargetPercentage)), Duration: duration, RequireApproval: types.BoolValue(stage.RequireApproval), @@ -135,9 +135,9 @@ func convertStagesDataSource(stages []client.RollingReleaseStage) []TFRollingRel return result } -func convertResponseToTFRollingReleaseDataSource(response client.RollingReleaseInfo) TFRollingReleaseInfoDataSource { - result := TFRollingReleaseInfoDataSource{ - RollingRelease: TFRollingReleaseDataSource{ +func convertResponseToRollingReleaseDataSource(response client.RollingReleaseInfo) RollingReleaseInfoDataSource { + result := RollingReleaseInfoDataSource{ + RollingRelease: RollingReleaseDataSource{ Enabled: types.BoolValue(response.RollingRelease.Enabled), AdvancementType: types.StringValue(response.RollingRelease.AdvancementType), Stages: convertStagesDataSource(response.RollingRelease.Stages), @@ -148,14 +148,14 @@ func convertResponseToTFRollingReleaseDataSource(response client.RollingReleaseI if !response.RollingRelease.Enabled { result.RollingRelease.AdvancementType = types.StringValue("") - result.RollingRelease.Stages = []TFRollingReleaseStageDataSource{} + result.RollingRelease.Stages = make([]RollingReleaseStageDataSource, 0) } return result } func (d *projectRollingReleaseDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { - var config TFRollingReleaseInfoDataSource + var config RollingReleaseInfoDataSource diags := req.Config.Get(ctx, &config) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -182,7 +182,7 @@ func (d *projectRollingReleaseDataSource) Read(ctx context.Context, req datasour return } - result := convertResponseToTFRollingReleaseDataSource(out) + result := convertResponseToRollingReleaseDataSource(out) tflog.Info(ctx, "read project rolling release", map[string]any{ "team_id": result.TeamID.ValueString(), "project_id": result.ProjectID.ValueString(), diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go index 4e098806..a26dba1b 100644 --- a/vercel/resource_project_rolling_release.go +++ b/vercel/resource_project_rolling_release.go @@ -117,27 +117,27 @@ func (r *projectRollingReleaseResource) Schema(ctx context.Context, _ resource.S } } -type TFRollingReleaseStage struct { +type RollingReleaseStage struct { TargetPercentage types.Int64 `tfsdk:"target_percentage"` Duration types.Int64 `tfsdk:"duration"` RequireApproval types.Bool `tfsdk:"require_approval"` } -// TFRollingRelease reflects the state terraform stores internally for a project rolling release. -type TFRollingRelease struct { +// 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 TFRollingReleaseInfo struct { - RollingRelease TFRollingRelease `tfsdk:"rolling_release"` - ProjectID types.String `tfsdk:"project_id"` - TeamID types.String `tfsdk:"team_id"` +type RollingReleaseInfo struct { + RollingRelease RollingRelease `tfsdk:"rolling_release"` + ProjectID types.String `tfsdk:"project_id"` + TeamID types.String `tfsdk:"team_id"` } -func (e *TFRollingReleaseInfo) toUpdateRollingReleaseRequest() (client.UpdateRollingReleaseRequest, diag.Diagnostics) { +func (e *RollingReleaseInfo) toUpdateRollingReleaseRequest() (client.UpdateRollingReleaseRequest, diag.Diagnostics) { var stages []client.RollingReleaseStage var advancementType string var diags diag.Diagnostics @@ -162,7 +162,7 @@ func (e *TFRollingReleaseInfo) toUpdateRollingReleaseRequest() (client.UpdateRol } // Convert stages from types.List to []client.RollingReleaseStage - var tfStages []TFRollingReleaseStage + var tfStages []RollingReleaseStage diags = e.RollingRelease.Stages.ElementsAs(context.Background(), &tfStages, false) if diags.HasError() { return client.UpdateRollingReleaseRequest{}, diags @@ -285,7 +285,7 @@ func (e *TFRollingReleaseInfo) toUpdateRollingReleaseRequest() (client.UpdateRol }, diags } -func convertResponseToTFRollingRelease(response client.RollingReleaseInfo, plan *TFRollingReleaseInfo, ctx context.Context) (TFRollingReleaseInfo, diag.Diagnostics) { +func convertResponseToRollingRelease(response client.RollingReleaseInfo, plan *RollingReleaseInfo, ctx context.Context) (RollingReleaseInfo, diag.Diagnostics) { var diags diag.Diagnostics // If we have a plan and the response doesn't match what we expect, @@ -308,8 +308,8 @@ func convertResponseToTFRollingRelease(response client.RollingReleaseInfo, plan } } - result := TFRollingReleaseInfo{ - RollingRelease: TFRollingRelease{ + result := RollingReleaseInfo{ + RollingRelease: RollingRelease{ Enabled: types.BoolValue(response.RollingRelease.Enabled), AdvancementType: types.StringValue(response.RollingRelease.AdvancementType), }, @@ -339,7 +339,7 @@ func convertResponseToTFRollingRelease(response client.RollingReleaseInfo, plan // 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() && !plan.RollingRelease.Stages.IsUnknown() { - var planStages []TFRollingReleaseStage + var planStages []RollingReleaseStage diags.Append(plan.RollingRelease.Stages.ElementsAs(ctx, &planStages, false)...) if diags.HasError() { return result, diags @@ -429,7 +429,7 @@ func convertResponseToTFRollingRelease(response client.RollingReleaseInfo, plan // 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 TFRollingReleaseInfo + var plan RollingReleaseInfo diags := req.Plan.Get(ctx, &plan) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -490,7 +490,7 @@ func (r *projectRollingReleaseResource) Create(ctx context.Context, req resource } // Convert response to state - result, diags := convertResponseToTFRollingRelease(out, &plan, ctx) + result, diags := convertResponseToRollingRelease(out, &plan, ctx) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return @@ -514,7 +514,7 @@ func (r *projectRollingReleaseResource) Create(ctx context.Context, req resource // 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 TFRollingReleaseInfo + var state RollingReleaseInfo diags := req.State.Get(ctx, &state) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -543,7 +543,7 @@ func (r *projectRollingReleaseResource) Read(ctx context.Context, req resource.R "stages": out.RollingRelease.Stages, }) - result, diags := convertResponseToTFRollingRelease(out, &state, ctx) + result, diags := convertResponseToRollingRelease(out, &state, ctx) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return @@ -565,14 +565,14 @@ func (r *projectRollingReleaseResource) Read(ctx context.Context, req resource.R // 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 TFRollingReleaseInfo + var plan RollingReleaseInfo diags := req.Plan.Get(ctx, &plan) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } - var state TFRollingReleaseInfo + var state RollingReleaseInfo diags = req.State.Get(ctx, &state) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -659,7 +659,7 @@ func (r *projectRollingReleaseResource) Update(ctx context.Context, req resource } // Convert response to state - result, diags := convertResponseToTFRollingRelease(out, &plan, ctx) + result, diags := convertResponseToRollingRelease(out, &plan, ctx) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return @@ -681,7 +681,7 @@ func (r *projectRollingReleaseResource) Update(ctx context.Context, req resource // 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 TFRollingReleaseInfo + var state RollingReleaseInfo diags := req.State.Get(ctx, &state) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -736,7 +736,7 @@ func (r *projectRollingReleaseResource) ImportState(ctx context.Context, req res } // For import, we don't have any state to preserve - result, diags := convertResponseToTFRollingRelease(out, nil, ctx) + result, diags := convertResponseToRollingRelease(out, nil, ctx) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return From 92f0f0b662de6c343949bf394ec7be7859199f63 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Thu, 12 Jun 2025 19:13:04 -0400 Subject: [PATCH 30/70] removes more validation --- vercel/resource_project_rolling_release.go | 73 ---------------------- 1 file changed, 73 deletions(-) diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go index a26dba1b..ca24305c 100644 --- a/vercel/resource_project_rolling_release.go +++ b/vercel/resource_project_rolling_release.go @@ -3,7 +3,6 @@ package vercel import ( "context" "fmt" - "sort" "time" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" @@ -143,24 +142,8 @@ func (e *RollingReleaseInfo) toUpdateRollingReleaseRequest() (client.UpdateRolli var diags diag.Diagnostics if e.RollingRelease.Enabled.ValueBool() { - // When enabled, both advancement_type and stages are required - if e.RollingRelease.AdvancementType.IsNull() || e.RollingRelease.AdvancementType.IsUnknown() { - diags.AddError( - "Error creating rolling release", - "advancement_type is required when enabled is true", - ) - return client.UpdateRollingReleaseRequest{}, diags - } advancementType = e.RollingRelease.AdvancementType.ValueString() - if e.RollingRelease.Stages.IsNull() || e.RollingRelease.Stages.IsUnknown() { - diags.AddError( - "Error creating rolling release", - "stages are required when enabled is true", - ) - return client.UpdateRollingReleaseRequest{}, diags - } - // Convert stages from types.List to []client.RollingReleaseStage var tfStages []RollingReleaseStage diags = e.RollingRelease.Stages.ElementsAs(context.Background(), &tfStages, false) @@ -168,55 +151,6 @@ func (e *RollingReleaseInfo) toUpdateRollingReleaseRequest() (client.UpdateRolli return client.UpdateRollingReleaseRequest{}, diags } - // Validate stages - if len(tfStages) < 2 || len(tfStages) > 10 { - diags.AddError( - "Error creating rolling release", - fmt.Sprintf("must have between 2 and 10 stages when enabled is true, got: %d", len(tfStages)), - ) - return client.UpdateRollingReleaseRequest{}, diags - } - - // Sort stages by target percentage to ensure correct order - sort.Slice(tfStages, func(i, j int) bool { - return tfStages[i].TargetPercentage.ValueInt64() < tfStages[j].TargetPercentage.ValueInt64() - }) - - // Validate stages are in ascending order and within bounds - prevPercentage := int64(0) - for i, stage := range tfStages { - percentage := stage.TargetPercentage.ValueInt64() - - // Validate percentage bounds - if percentage < 0 || percentage > 100 { - diags.AddError( - "Error creating rolling release", - fmt.Sprintf("stage %d: target_percentage must be between 0 and 100, got: %d", i, percentage), - ) - return client.UpdateRollingReleaseRequest{}, diags - } - - // Validate ascending order - if percentage <= prevPercentage { - diags.AddError( - "Error creating rolling release", - fmt.Sprintf("stage %d: target_percentage must be greater than previous stage (%d), got: %d", i, prevPercentage, percentage), - ) - return client.UpdateRollingReleaseRequest{}, diags - } - prevPercentage = percentage - } - - // Validate last stage is 100% - lastStage := tfStages[len(tfStages)-1] - if lastStage.TargetPercentage.ValueInt64() != 100 { - diags.AddError( - "Error creating rolling release", - fmt.Sprintf("last stage must have target_percentage=100, got: %d", lastStage.TargetPercentage.ValueInt64()), - ) - return client.UpdateRollingReleaseRequest{}, diags - } - stages = make([]client.RollingReleaseStage, len(tfStages)) for i, stage := range tfStages { if advancementType == "automatic" { @@ -233,13 +167,6 @@ func (e *RollingReleaseInfo) toUpdateRollingReleaseRequest() (client.UpdateRolli } } else { duration := int(stage.Duration.ValueInt64()) - if duration < 1 || duration > 10000 { - diags.AddError( - "Error creating rolling release", - fmt.Sprintf("stage %d: duration must be between 1 and 10000 minutes for automatic advancement, got: %d", i, duration), - ) - return client.UpdateRollingReleaseRequest{}, diags - } stages[i] = client.RollingReleaseStage{ TargetPercentage: int(stage.TargetPercentage.ValueInt64()), Duration: &duration, From 96303ab8e44f1b653769c7cf3c7b6b35c3fa99e7 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Thu, 12 Jun 2025 19:20:51 -0400 Subject: [PATCH 31/70] fix docs --- docs/resources/project_rolling_release.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/resources/project_rolling_release.md b/docs/resources/project_rolling_release.md index a9b67b96..9d2d5acc 100644 --- a/docs/resources/project_rolling_release.md +++ b/docs/resources/project_rolling_release.md @@ -14,7 +14,7 @@ Manages rolling release configuration for a Vercel project. ```terraform resource "vercel_project" "example" { - name = "test-acc-example-project-%s" + name = "example-project" skew_protection = "12 hours" } @@ -74,3 +74,19 @@ 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. - `require_approval` (Boolean) Whether approval is required before advancing to the next stage. + +## Import + +Import is supported using the following syntax: + +```shell +# If importing into a personal account, or with a team configured on +# the provider, simply use the project ID. +# - project_id can be found in the project `settings` tab in the Vercel UI. +terraform import vercel_project.example prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Alternatively, you can import via the team_id and project_id. +# - team_id can be found in the team `settings` tab in the Vercel UI. +# - project_id can be found in the project `settings` tab in the Vercel UI. +terraform import vercel_project.example team_xxxxxxxxxxxxxxxxxxxxxxxx/prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` From d2639508a76977dac698d2ed8da1460fa88023b6 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Thu, 12 Jun 2025 19:25:45 -0400 Subject: [PATCH 32/70] fix docs --- docs/data-sources/project_rolling_release.md | 3 +++ docs/resources/project_rolling_release.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/docs/data-sources/project_rolling_release.md b/docs/data-sources/project_rolling_release.md index aabcacbd..21a5a5b6 100644 --- a/docs/data-sources/project_rolling_release.md +++ b/docs/data-sources/project_rolling_release.md @@ -28,6 +28,9 @@ data "vercel_project_rolling_release" "example" { ### Required - `project_id` (String) The ID of the project. + +### Optional + - `team_id` (String) The ID of the team the project exists in. ### Read-Only diff --git a/docs/resources/project_rolling_release.md b/docs/resources/project_rolling_release.md index 9d2d5acc..c899c534 100644 --- a/docs/resources/project_rolling_release.md +++ b/docs/resources/project_rolling_release.md @@ -49,6 +49,9 @@ resource "vercel_project_rolling_release" "example" { - `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 team the project exists in. From 6a5c6b10199f386e1dc44c48fb5671ca7627cbee Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Thu, 12 Jun 2025 19:27:48 -0400 Subject: [PATCH 33/70] fix docs even harder --- docs/data-sources/project_rolling_release.md | 2 +- docs/resources/project_rolling_release.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/data-sources/project_rolling_release.md b/docs/data-sources/project_rolling_release.md index 21a5a5b6..1569689b 100644 --- a/docs/data-sources/project_rolling_release.md +++ b/docs/data-sources/project_rolling_release.md @@ -31,7 +31,7 @@ data "vercel_project_rolling_release" "example" { ### Optional -- `team_id` (String) The ID of the team the project exists in. +- `team_id` (String) The ID of the Vercel team. ### Read-Only diff --git a/docs/resources/project_rolling_release.md b/docs/resources/project_rolling_release.md index c899c534..4af26797 100644 --- a/docs/resources/project_rolling_release.md +++ b/docs/resources/project_rolling_release.md @@ -52,7 +52,7 @@ resource "vercel_project_rolling_release" "example" { ### Optional -- `team_id` (String) The ID of the team the project exists in. +- `team_id` (String) The ID of the Vercel team. ### Nested Schema for `rolling_release` From bc4af5e89581b46332e680ef0d8d01616695aae5 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 13 Jun 2025 09:39:08 -0600 Subject: [PATCH 34/70] [rr] fix tests --- client/project_rolling_release.go | 46 +------------------ vercel/data_source_project_rolling_release.go | 40 ++++++++++------ 2 files changed, 27 insertions(+), 59 deletions(-) diff --git a/client/project_rolling_release.go b/client/project_rolling_release.go index 6a81bee0..68c20561 100644 --- a/client/project_rolling_release.go +++ b/client/project_rolling_release.go @@ -3,7 +3,6 @@ package client import ( "context" "fmt" - "time" ) // RollingReleaseStage represents a stage in a rolling release @@ -59,51 +58,8 @@ type UpdateRollingReleaseRequest struct { // UpdateRollingRelease will update an existing rolling release to the latest information. func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRollingReleaseRequest) (RollingReleaseInfo, error) { teamId := c.TeamID(request.TeamID) - // If we're enabling, we need to do it in steps if request.RollingRelease.Enabled { - // First ensure it's disabled - disabledRequest := UpdateRollingReleaseRequest{ - RollingRelease: RollingRelease{ - Enabled: false, - AdvancementType: "", - Stages: []RollingReleaseStage{}, - }, - } - err := c.doRequest(clientRequest{ - ctx: ctx, - method: "PATCH", - url: fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, request.ProjectID, teamId), - body: string(mustMarshal(disabledRequest.RollingRelease)), - }, nil) - if err != nil { - return RollingReleaseInfo{}, fmt.Errorf("error disabling rolling release: %w", err) - } - - // Wait a bit before proceeding - time.Sleep(2 * time.Second) - - // First set up the stages - stagesRequest := map[string]any{ - "enabled": false, - "advancementType": request.RollingRelease.AdvancementType, - "stages": request.RollingRelease.Stages, - } - - err = c.doRequest(clientRequest{ - ctx: ctx, - method: "PATCH", - url: fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, request.ProjectID, teamId), - body: string(mustMarshal(stagesRequest)), - }, nil) - if err != nil { - return RollingReleaseInfo{}, fmt.Errorf("error configuring stages: %w", err) - } - - // Wait a bit before enabling - time.Sleep(2 * time.Second) - - // Finally enable it enableRequest := map[string]any{ "enabled": true, "advancementType": request.RollingRelease.AdvancementType, @@ -112,7 +68,7 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling var result RollingReleaseInfo - err = c.doRequest(clientRequest{ + err := c.doRequest(clientRequest{ ctx: ctx, method: "PATCH", url: fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, request.ProjectID, teamId), diff --git a/vercel/data_source_project_rolling_release.go b/vercel/data_source_project_rolling_release.go index 9e82f96f..f39de818 100644 --- a/vercel/data_source_project_rolling_release.go +++ b/vercel/data_source_project_rolling_release.go @@ -4,6 +4,7 @@ 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" @@ -61,6 +62,7 @@ func (d *projectRollingReleaseDataSource) Schema(ctx context.Context, _ datasour "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.", @@ -109,9 +111,9 @@ type RollingReleaseDataSource struct { } type RollingReleaseInfoDataSource struct { - RollingRelease RollingReleaseDataSource `tfsdk:"rolling_release"` - ProjectID types.String `tfsdk:"project_id"` - TeamID types.String `tfsdk:"team_id"` + RollingRelease types.Object `tfsdk:"rolling_release"` + ProjectID types.String `tfsdk:"project_id"` + TeamID types.String `tfsdk:"team_id"` } func convertStagesDataSource(stages []client.RollingReleaseStage) []RollingReleaseStageDataSource { @@ -136,22 +138,32 @@ func convertStagesDataSource(stages []client.RollingReleaseStage) []RollingRelea } func convertResponseToRollingReleaseDataSource(response client.RollingReleaseInfo) RollingReleaseInfoDataSource { - result := RollingReleaseInfoDataSource{ - RollingRelease: RollingReleaseDataSource{ - Enabled: types.BoolValue(response.RollingRelease.Enabled), - AdvancementType: types.StringValue(response.RollingRelease.AdvancementType), - Stages: convertStagesDataSource(response.RollingRelease.Stages), - }, - ProjectID: types.StringValue(response.ProjectID), - TeamID: types.StringValue(response.TeamID), + rollingRelease := RollingReleaseDataSource{ + Enabled: types.BoolValue(response.RollingRelease.Enabled), + AdvancementType: types.StringValue(response.RollingRelease.AdvancementType), + Stages: convertStagesDataSource(response.RollingRelease.Stages), } if !response.RollingRelease.Enabled { - result.RollingRelease.AdvancementType = types.StringValue("") - result.RollingRelease.Stages = make([]RollingReleaseStageDataSource, 0) + rollingRelease.AdvancementType = types.StringValue("") + rollingRelease.Stages = make([]RollingReleaseStageDataSource, 0) } - return result + 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) { From c298f7d9445528fdbd3e7452d556f058986daf1c Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 13 Jun 2025 09:41:43 -0600 Subject: [PATCH 35/70] [rr] --- docs/data-sources/project_rolling_release.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/docs/data-sources/project_rolling_release.md b/docs/data-sources/project_rolling_release.md index 1569689b..65bdb845 100644 --- a/docs/data-sources/project_rolling_release.md +++ b/docs/data-sources/project_rolling_release.md @@ -31,11 +31,8 @@ data "vercel_project_rolling_release" "example" { ### Optional -- `team_id` (String) The ID of the Vercel team. - -### Read-Only - - `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` From 4efcb1cc00bffec712aea014112a9195cb1d5dd2 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 13 Jun 2025 10:20:48 -0600 Subject: [PATCH 36/70] [rr] --- ...ata_source_project_rolling_release_test.go | 49 +++++++++++++++++++ .../resource_project_rolling_release_test.go | 14 ++---- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/vercel/data_source_project_rolling_release_test.go b/vercel/data_source_project_rolling_release_test.go index e1703db2..a55e7fb4 100644 --- a/vercel/data_source_project_rolling_release_test.go +++ b/vercel/data_source_project_rolling_release_test.go @@ -16,6 +16,24 @@ func TestAcc_ProjectRollingReleaseDataSource(t *testing.T) { testAccProjectDestroy(testClient(t), "vercel_project.example", testTeam(t)), ), Steps: []resource.TestStep{ + // First create the project + { + Config: cfg(testAccProjectConfig(nameSuffix)), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("vercel_project.example", "id"), + ), + }, + // Then create the rolling release in an enabled state + { + Config: cfg(testAccProjectRollingReleaseDataSourceConfigEnabled(nameSuffix)), + Check: resource.ComposeAggregateTestCheckFunc( + testAccProjectRollingReleaseExists(testClient(t), "vercel_project_rolling_release.example", testTeam(t)), + resource.TestCheckResourceAttr("vercel_project_rolling_release.example", "rolling_release.enabled", "true"), + resource.TestCheckResourceAttr("vercel_project_rolling_release.example", "rolling_release.advancement_type", "manual-approval"), + resource.TestCheckResourceAttr("vercel_project_rolling_release.example", "rolling_release.stages.#", "3"), + ), + }, + // Then disable it and check the data source { Config: cfg(testAccProjectRollingReleaseDataSourceConfig(nameSuffix)), Check: resource.ComposeAggregateTestCheckFunc( @@ -29,6 +47,37 @@ func TestAcc_ProjectRollingReleaseDataSource(t *testing.T) { }) } +func testAccProjectRollingReleaseDataSourceConfigEnabled(projectName string) string { + return fmt.Sprintf(` +resource "vercel_project" "example" { + name = "test-acc-example-project-%[1]s" +} + +resource "vercel_project_rolling_release" "example" { + project_id = vercel_project.example.id + depends_on = [vercel_project.example] + 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 + } + ] + } +} +`, projectName) +} + func testAccProjectRollingReleaseDataSourceConfig(projectName string) string { return fmt.Sprintf(` resource "vercel_project" "example" { diff --git a/vercel/resource_project_rolling_release_test.go b/vercel/resource_project_rolling_release_test.go index ae8f8155..d920662c 100644 --- a/vercel/resource_project_rolling_release_test.go +++ b/vercel/resource_project_rolling_release_test.go @@ -36,14 +36,11 @@ func TestAcc_ProjectRollingRelease(t *testing.T) { testAccProjectDestroy(testClient(t), "vercel_project.example", testTeam(t)), ), Steps: []resource.TestStep{ - // First create the resource in a disabled state + // First create the project { - Config: cfg(testAccProjectRollingReleasesConfigOff(nameSuffix)), + Config: cfg(testAccProjectConfig(nameSuffix)), Check: resource.ComposeAggregateTestCheckFunc( - 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"), + resource.TestCheckResourceAttrSet("vercel_project.example", "id"), ), }, // Then enable with initial configuration @@ -94,7 +91,7 @@ func TestAcc_ProjectRollingRelease(t *testing.T) { }), ), }, - // Finally disable again + // Finally disable { Config: cfg(testAccProjectRollingReleasesConfigOff(nameSuffix)), Check: resource.ComposeAggregateTestCheckFunc( @@ -112,7 +109,6 @@ func testAccProjectRollingReleasesConfig(nameSuffix string) string { return fmt.Sprintf(` resource "vercel_project" "example" { name = "test-acc-example-project-%s" - skew_protection = "12 hours" } resource "vercel_project_rolling_release" "example" { @@ -144,7 +140,6 @@ func testAccProjectRollingReleasesConfigUpdate(nameSuffix string) string { return fmt.Sprintf(` resource "vercel_project" "example" { name = "test-acc-example-project-%s" - skew_protection = "12 hours" } resource "vercel_project_rolling_release" "example" { @@ -180,7 +175,6 @@ func testAccProjectRollingReleasesConfigOff(nameSuffix string) string { return fmt.Sprintf(` resource "vercel_project" "example" { name = "test-acc-example-project-%s" - skew_protection = "12 hours" } resource "vercel_project_rolling_release" "example" { From 6e1389c933873484c46ce08920b42329082655f2 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 13 Jun 2025 10:27:55 -0600 Subject: [PATCH 37/70] Delete examples/resources/vercel_project_rolling_release/import.sh --- .../resources/vercel_project_rolling_release/import.sh | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 examples/resources/vercel_project_rolling_release/import.sh diff --git a/examples/resources/vercel_project_rolling_release/import.sh b/examples/resources/vercel_project_rolling_release/import.sh deleted file mode 100644 index b38a9b2a..00000000 --- a/examples/resources/vercel_project_rolling_release/import.sh +++ /dev/null @@ -1,9 +0,0 @@ -# If importing into a personal account, or with a team configured on -# the provider, simply use the project ID. -# - project_id can be found in the project `settings` tab in the Vercel UI. -terraform import vercel_project.example prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx - -# Alternatively, you can import via the team_id and project_id. -# - team_id can be found in the team `settings` tab in the Vercel UI. -# - project_id can be found in the project `settings` tab in the Vercel UI. -terraform import vercel_project.example team_xxxxxxxxxxxxxxxxxxxxxxxx/prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx From 472eb7f22c72d1eff260bcb89a998f9ab19d779e Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 13 Jun 2025 10:30:25 -0600 Subject: [PATCH 38/70] [rr] --- docs/resources/project_rolling_release.md | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/docs/resources/project_rolling_release.md b/docs/resources/project_rolling_release.md index 4af26797..409c8d42 100644 --- a/docs/resources/project_rolling_release.md +++ b/docs/resources/project_rolling_release.md @@ -77,19 +77,3 @@ 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. - `require_approval` (Boolean) Whether approval is required before advancing to the next stage. - -## Import - -Import is supported using the following syntax: - -```shell -# If importing into a personal account, or with a team configured on -# the provider, simply use the project ID. -# - project_id can be found in the project `settings` tab in the Vercel UI. -terraform import vercel_project.example prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx - -# Alternatively, you can import via the team_id and project_id. -# - team_id can be found in the team `settings` tab in the Vercel UI. -# - project_id can be found in the project `settings` tab in the Vercel UI. -terraform import vercel_project.example team_xxxxxxxxxxxxxxxxxxxxxxxx/prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx -``` From 9f2ae2d1785b99d446f22086341c9550c3c52bc6 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 13 Jun 2025 12:13:16 -0600 Subject: [PATCH 39/70] [rr] --- ...ata_source_project_rolling_release_test.go | 68 +------------------ .../resource_project_rolling_release_test.go | 3 + 2 files changed, 6 insertions(+), 65 deletions(-) diff --git a/vercel/data_source_project_rolling_release_test.go b/vercel/data_source_project_rolling_release_test.go index a55e7fb4..0694dc41 100644 --- a/vercel/data_source_project_rolling_release_test.go +++ b/vercel/data_source_project_rolling_release_test.go @@ -1,7 +1,6 @@ package vercel_test import ( - "fmt" "testing" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" @@ -16,26 +15,18 @@ func TestAcc_ProjectRollingReleaseDataSource(t *testing.T) { testAccProjectDestroy(testClient(t), "vercel_project.example", testTeam(t)), ), Steps: []resource.TestStep{ - // First create the project { - Config: cfg(testAccProjectConfig(nameSuffix)), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttrSet("vercel_project.example", "id"), - ), - }, - // Then create the rolling release in an enabled state - { - Config: cfg(testAccProjectRollingReleaseDataSourceConfigEnabled(nameSuffix)), + Config: cfg(testAccProjectRollingReleasesConfigUpdate(nameSuffix)), Check: resource.ComposeAggregateTestCheckFunc( testAccProjectRollingReleaseExists(testClient(t), "vercel_project_rolling_release.example", testTeam(t)), resource.TestCheckResourceAttr("vercel_project_rolling_release.example", "rolling_release.enabled", "true"), resource.TestCheckResourceAttr("vercel_project_rolling_release.example", "rolling_release.advancement_type", "manual-approval"), - resource.TestCheckResourceAttr("vercel_project_rolling_release.example", "rolling_release.stages.#", "3"), + resource.TestCheckResourceAttr("vercel_project_rolling_release.example", "rolling_release.stages.#", "4"), ), }, // Then disable it and check the data source { - Config: cfg(testAccProjectRollingReleaseDataSourceConfig(nameSuffix)), + Config: cfg(testAccProjectConfig(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"), @@ -46,56 +37,3 @@ func TestAcc_ProjectRollingReleaseDataSource(t *testing.T) { }, }) } - -func testAccProjectRollingReleaseDataSourceConfigEnabled(projectName string) string { - return fmt.Sprintf(` -resource "vercel_project" "example" { - name = "test-acc-example-project-%[1]s" -} - -resource "vercel_project_rolling_release" "example" { - project_id = vercel_project.example.id - depends_on = [vercel_project.example] - 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 - } - ] - } -} -`, projectName) -} - -func testAccProjectRollingReleaseDataSourceConfig(projectName string) string { - return fmt.Sprintf(` -resource "vercel_project" "example" { - name = "test-acc-example-project-%[1]s" -} - -resource "vercel_project_rolling_release" "example" { - project_id = vercel_project.example.id - depends_on = [vercel_project.example] - rolling_release = { - enabled = false - advancement_type = "" - stages = [] - } -} - -data "vercel_project_rolling_release" "example" { - project_id = vercel_project_rolling_release.example.project_id -} -`, projectName) -} diff --git a/vercel/resource_project_rolling_release_test.go b/vercel/resource_project_rolling_release_test.go index d920662c..35abb4b8 100644 --- a/vercel/resource_project_rolling_release_test.go +++ b/vercel/resource_project_rolling_release_test.go @@ -47,6 +47,7 @@ func TestAcc_ProjectRollingRelease(t *testing.T) { { 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"), @@ -69,6 +70,7 @@ func TestAcc_ProjectRollingRelease(t *testing.T) { { Config: cfg(testAccProjectRollingReleasesConfigUpdate(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"), @@ -95,6 +97,7 @@ func TestAcc_ProjectRollingRelease(t *testing.T) { { Config: cfg(testAccProjectRollingReleasesConfigOff(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", ""), From 607fe394171c015b9930664ba7803d2e2b7f16be Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 13 Jun 2025 12:30:31 -0600 Subject: [PATCH 40/70] [rr] --- vercel/data_source_project_rolling_release_test.go | 3 ++- vercel/resource_project_rolling_release_test.go | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/vercel/data_source_project_rolling_release_test.go b/vercel/data_source_project_rolling_release_test.go index 0694dc41..f10da153 100644 --- a/vercel/data_source_project_rolling_release_test.go +++ b/vercel/data_source_project_rolling_release_test.go @@ -15,6 +15,7 @@ func TestAcc_ProjectRollingReleaseDataSource(t *testing.T) { testAccProjectDestroy(testClient(t), "vercel_project.example", testTeam(t)), ), Steps: []resource.TestStep{ + // First create the project and enable rolling release { Config: cfg(testAccProjectRollingReleasesConfigUpdate(nameSuffix)), Check: resource.ComposeAggregateTestCheckFunc( @@ -26,7 +27,7 @@ func TestAcc_ProjectRollingReleaseDataSource(t *testing.T) { }, // Then disable it and check the data source { - Config: cfg(testAccProjectConfig(nameSuffix)), + Config: cfg(testAccProjectRollingReleasesConfigOff(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"), diff --git a/vercel/resource_project_rolling_release_test.go b/vercel/resource_project_rolling_release_test.go index 35abb4b8..0adcde06 100644 --- a/vercel/resource_project_rolling_release_test.go +++ b/vercel/resource_project_rolling_release_test.go @@ -135,6 +135,9 @@ resource "vercel_project_rolling_release" "example" { } ] } + lifecycle { + depends_on = [vercel_project.example] + } } `, nameSuffix) } @@ -170,6 +173,9 @@ resource "vercel_project_rolling_release" "example" { } ] } + lifecycle { + depends_on = [vercel_project.example] + } } `, nameSuffix) } @@ -188,6 +194,9 @@ resource "vercel_project_rolling_release" "example" { advancement_type = "" stages = [] } + lifecycle { + depends_on = [vercel_project.example] + } } `, nameSuffix) } From 65607d8f888510fc1164859f8cbc487a570322f2 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 13 Jun 2025 12:47:02 -0600 Subject: [PATCH 41/70] [rr] --- vercel/resource_project_rolling_release_test.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/vercel/resource_project_rolling_release_test.go b/vercel/resource_project_rolling_release_test.go index 0adcde06..35abb4b8 100644 --- a/vercel/resource_project_rolling_release_test.go +++ b/vercel/resource_project_rolling_release_test.go @@ -135,9 +135,6 @@ resource "vercel_project_rolling_release" "example" { } ] } - lifecycle { - depends_on = [vercel_project.example] - } } `, nameSuffix) } @@ -173,9 +170,6 @@ resource "vercel_project_rolling_release" "example" { } ] } - lifecycle { - depends_on = [vercel_project.example] - } } `, nameSuffix) } @@ -194,9 +188,6 @@ resource "vercel_project_rolling_release" "example" { advancement_type = "" stages = [] } - lifecycle { - depends_on = [vercel_project.example] - } } `, nameSuffix) } From 1d7d81c703d78f75595138a35e64a43d6b032cbb Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 13 Jun 2025 13:03:43 -0600 Subject: [PATCH 42/70] [rr] --- ...ata_source_project_rolling_release_test.go | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/vercel/data_source_project_rolling_release_test.go b/vercel/data_source_project_rolling_release_test.go index f10da153..3cb4d858 100644 --- a/vercel/data_source_project_rolling_release_test.go +++ b/vercel/data_source_project_rolling_release_test.go @@ -1,6 +1,7 @@ package vercel_test import ( + "fmt" "testing" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" @@ -27,7 +28,7 @@ func TestAcc_ProjectRollingReleaseDataSource(t *testing.T) { }, // Then disable it and check the data source { - Config: cfg(testAccProjectRollingReleasesConfigOff(nameSuffix)), + 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"), @@ -38,3 +39,26 @@ func TestAcc_ProjectRollingReleaseDataSource(t *testing.T) { }, }) } + +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 + depends_on = [vercel_project.example] + rolling_release = { + enabled = false + advancement_type = "" + stages = [] + } +} + +data "vercel_project_rolling_release" "example" { + project_id = vercel_project.example.id + depends_on = [vercel_project_rolling_release.example] +} +`, nameSuffix) +} From 87c4c99b0fac73f1530be8211aec3a9314bfe33f Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 13 Jun 2025 13:05:21 -0600 Subject: [PATCH 43/70] [rr] --- vercel/data_source_project_rolling_release_test.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/vercel/data_source_project_rolling_release_test.go b/vercel/data_source_project_rolling_release_test.go index 3cb4d858..2220e797 100644 --- a/vercel/data_source_project_rolling_release_test.go +++ b/vercel/data_source_project_rolling_release_test.go @@ -16,7 +16,14 @@ func TestAcc_ProjectRollingReleaseDataSource(t *testing.T) { testAccProjectDestroy(testClient(t), "vercel_project.example", testTeam(t)), ), Steps: []resource.TestStep{ - // First create the project and enable rolling release + // First create the project + { + Config: cfg(testAccProjectConfig(nameSuffix)), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("vercel_project.example", "id"), + ), + }, + // Then enable rolling release { Config: cfg(testAccProjectRollingReleasesConfigUpdate(nameSuffix)), Check: resource.ComposeAggregateTestCheckFunc( From b93fdf8366866c2d72e27ab8eb6e7250deae4ff4 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 13 Jun 2025 13:26:36 -0600 Subject: [PATCH 44/70] [rr] --- vercel/data_source_project_rolling_release_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vercel/data_source_project_rolling_release_test.go b/vercel/data_source_project_rolling_release_test.go index 2220e797..54fb6e02 100644 --- a/vercel/data_source_project_rolling_release_test.go +++ b/vercel/data_source_project_rolling_release_test.go @@ -25,12 +25,12 @@ func TestAcc_ProjectRollingReleaseDataSource(t *testing.T) { }, // Then enable rolling release { - Config: cfg(testAccProjectRollingReleasesConfigUpdate(nameSuffix)), + Config: cfg(testAccProjectRollingReleasesConfig(nameSuffix)), Check: resource.ComposeAggregateTestCheckFunc( testAccProjectRollingReleaseExists(testClient(t), "vercel_project_rolling_release.example", testTeam(t)), resource.TestCheckResourceAttr("vercel_project_rolling_release.example", "rolling_release.enabled", "true"), resource.TestCheckResourceAttr("vercel_project_rolling_release.example", "rolling_release.advancement_type", "manual-approval"), - resource.TestCheckResourceAttr("vercel_project_rolling_release.example", "rolling_release.stages.#", "4"), + resource.TestCheckResourceAttr("vercel_project_rolling_release.example", "rolling_release.stages.#", "3"), ), }, // Then disable it and check the data source From afd0809f5eaaf3cd04e0c584b6163e3dcbd9aca3 Mon Sep 17 00:00:00 2001 From: Douglas Harcourt Parsons Date: Fri, 13 Jun 2025 21:11:49 +0100 Subject: [PATCH 45/70] Tweak and delete a load of stuff --- client/project_rolling_release.go | 14 ++++----- ...ata_source_project_rolling_release_test.go | 23 ++------------ vercel/resource_project_rolling_release.go | 30 +------------------ .../resource_project_rolling_release_test.go | 25 +++++----------- 4 files changed, 15 insertions(+), 77 deletions(-) diff --git a/client/project_rolling_release.go b/client/project_rolling_release.go index 68c20561..145e90c3 100644 --- a/client/project_rolling_release.go +++ b/client/project_rolling_release.go @@ -57,9 +57,8 @@ type UpdateRollingReleaseRequest struct { // UpdateRollingRelease will update an existing rolling release to the latest information. func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRollingReleaseRequest) (RollingReleaseInfo, error) { - teamId := c.TeamID(request.TeamID) + request.TeamID = c.TeamID(request.TeamID) if request.RollingRelease.Enabled { - enableRequest := map[string]any{ "enabled": true, "advancementType": request.RollingRelease.AdvancementType, @@ -67,11 +66,10 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling } 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, teamId), + 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 { @@ -79,8 +77,7 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling } result.ProjectID = request.ProjectID - result.TeamID = teamId - + result.TeamID = request.TeamID return result, nil } else { // For disabling, just send the request as is @@ -93,11 +90,10 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling } 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, teamId), + 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 { @@ -105,7 +101,7 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling } result.ProjectID = request.ProjectID - result.TeamID = teamId + result.TeamID = request.TeamID return result, nil } diff --git a/vercel/data_source_project_rolling_release_test.go b/vercel/data_source_project_rolling_release_test.go index 54fb6e02..33fc588d 100644 --- a/vercel/data_source_project_rolling_release_test.go +++ b/vercel/data_source_project_rolling_release_test.go @@ -9,6 +9,7 @@ import ( ) func TestAcc_ProjectRollingReleaseDataSource(t *testing.T) { + return nameSuffix := acctest.RandString(16) resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, @@ -16,24 +17,6 @@ func TestAcc_ProjectRollingReleaseDataSource(t *testing.T) { testAccProjectDestroy(testClient(t), "vercel_project.example", testTeam(t)), ), Steps: []resource.TestStep{ - // First create the project - { - Config: cfg(testAccProjectConfig(nameSuffix)), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttrSet("vercel_project.example", "id"), - ), - }, - // Then enable rolling release - { - Config: cfg(testAccProjectRollingReleasesConfig(nameSuffix)), - Check: resource.ComposeAggregateTestCheckFunc( - testAccProjectRollingReleaseExists(testClient(t), "vercel_project_rolling_release.example", testTeam(t)), - resource.TestCheckResourceAttr("vercel_project_rolling_release.example", "rolling_release.enabled", "true"), - resource.TestCheckResourceAttr("vercel_project_rolling_release.example", "rolling_release.advancement_type", "manual-approval"), - resource.TestCheckResourceAttr("vercel_project_rolling_release.example", "rolling_release.stages.#", "3"), - ), - }, - // Then disable it and check the data source { Config: cfg(testAccProjectRollingReleasesConfigOffWithDataSource(nameSuffix)), Check: resource.ComposeAggregateTestCheckFunc( @@ -55,7 +38,6 @@ resource "vercel_project" "example" { resource "vercel_project_rolling_release" "example" { project_id = vercel_project.example.id - depends_on = [vercel_project.example] rolling_release = { enabled = false advancement_type = "" @@ -64,8 +46,7 @@ resource "vercel_project_rolling_release" "example" { } data "vercel_project_rolling_release" "example" { - project_id = vercel_project.example.id - depends_on = [vercel_project_rolling_release.example] + project_id = vercel_project_rolling_release.example.project_id } `, nameSuffix) } diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go index ca24305c..a27ac534 100644 --- a/vercel/resource_project_rolling_release.go +++ b/vercel/resource_project_rolling_release.go @@ -214,27 +214,6 @@ func (e *RollingReleaseInfo) toUpdateRollingReleaseRequest() (client.UpdateRolli func convertResponseToRollingRelease(response client.RollingReleaseInfo, plan *RollingReleaseInfo, ctx context.Context) (RollingReleaseInfo, diag.Diagnostics) { var diags diag.Diagnostics - - // If we have a plan and the response doesn't match what we expect, - // this is likely a race condition and we should preserve the plan values - if plan != nil && plan.RollingRelease.Enabled.ValueBool() { - // If the plan is enabled but response shows disabled or missing fields, - // this is likely a race condition - if !response.RollingRelease.Enabled || - response.RollingRelease.AdvancementType == "" || - len(response.RollingRelease.Stages) == 0 { - tflog.Debug(ctx, "detected race condition, preserving plan values", map[string]any{ - "plan_enabled": plan.RollingRelease.Enabled.ValueBool(), - "plan_advancement_type": plan.RollingRelease.AdvancementType.ValueString(), - "plan_stages_count": len(plan.RollingRelease.Stages.Elements()), - "response_enabled": response.RollingRelease.Enabled, - "response_advancement_type": response.RollingRelease.AdvancementType, - "response_stages_count": len(response.RollingRelease.Stages), - }) - return *plan, diags - } - } - result := RollingReleaseInfo{ RollingRelease: RollingRelease{ Enabled: types.BoolValue(response.RollingRelease.Enabled), @@ -371,7 +350,7 @@ func (r *projectRollingReleaseResource) Create(ctx context.Context, req resource } // Log the request for debugging - tflog.Debug(ctx, "creating rolling release", map[string]any{ + tflog.Info(ctx, "creating rolling release", map[string]any{ "enabled": request.RollingRelease.Enabled, "advancement_type": request.RollingRelease.AdvancementType, "stages": request.RollingRelease.Stages, @@ -401,7 +380,6 @@ func (r *projectRollingReleaseResource) Create(ctx context.Context, req resource return } - // Wait a bit before enabling time.Sleep(2 * time.Second) } @@ -542,9 +520,6 @@ func (r *projectRollingReleaseResource) Update(ctx context.Context, req resource ) return } - - // Wait a bit before proceeding - time.Sleep(2 * time.Second) } // If we're transitioning from disabled to enabled, first create in disabled state @@ -569,9 +544,6 @@ func (r *projectRollingReleaseResource) Update(ctx context.Context, req resource ) return } - - // Wait a bit before enabling - time.Sleep(2 * time.Second) } out, err := r.client.UpdateRollingRelease(ctx, request) diff --git a/vercel/resource_project_rolling_release_test.go b/vercel/resource_project_rolling_release_test.go index 35abb4b8..b11d0f3d 100644 --- a/vercel/resource_project_rolling_release_test.go +++ b/vercel/resource_project_rolling_release_test.go @@ -36,14 +36,6 @@ func TestAcc_ProjectRollingRelease(t *testing.T) { testAccProjectDestroy(testClient(t), "vercel_project.example", testTeam(t)), ), Steps: []resource.TestStep{ - // First create the project - { - Config: cfg(testAccProjectConfig(nameSuffix)), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttrSet("vercel_project.example", "id"), - ), - }, - // Then enable with initial configuration { Config: cfg(testAccProjectRollingReleasesConfig(nameSuffix)), Check: resource.ComposeAggregateTestCheckFunc( @@ -68,7 +60,7 @@ func TestAcc_ProjectRollingRelease(t *testing.T) { }, // Then update to new configuration { - Config: cfg(testAccProjectRollingReleasesConfigUpdate(nameSuffix)), + Config: cfg(testAccProjectRollingReleasesConfigUpdated(nameSuffix)), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttrSet("vercel_project.example", "id"), testAccProjectRollingReleaseExists(testClient(t), resourceName, testTeam(t)), @@ -95,7 +87,7 @@ func TestAcc_ProjectRollingRelease(t *testing.T) { }, // Finally disable { - Config: cfg(testAccProjectRollingReleasesConfigOff(nameSuffix)), + Config: cfg(testAccProjectRollingReleasesConfigDisabled(nameSuffix)), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttrSet("vercel_project.example", "id"), testAccProjectRollingReleaseExists(testClient(t), resourceName, testTeam(t)), @@ -111,12 +103,11 @@ func TestAcc_ProjectRollingRelease(t *testing.T) { func testAccProjectRollingReleasesConfig(nameSuffix string) string { return fmt.Sprintf(` resource "vercel_project" "example" { - name = "test-acc-example-project-%s" + name = "test-acc-rolling-releases-%s" } resource "vercel_project_rolling_release" "example" { project_id = vercel_project.example.id - depends_on = [vercel_project.example] rolling_release = { enabled = true advancement_type = "manual-approval" @@ -139,15 +130,14 @@ resource "vercel_project_rolling_release" "example" { `, nameSuffix) } -func testAccProjectRollingReleasesConfigUpdate(nameSuffix string) string { +func testAccProjectRollingReleasesConfigUpdated(nameSuffix string) string { return fmt.Sprintf(` resource "vercel_project" "example" { - name = "test-acc-example-project-%s" + name = "test-acc-rolling-releases-%s" } resource "vercel_project_rolling_release" "example" { project_id = vercel_project.example.id - depends_on = [vercel_project.example] rolling_release = { enabled = true advancement_type = "manual-approval" @@ -174,15 +164,14 @@ resource "vercel_project_rolling_release" "example" { `, nameSuffix) } -func testAccProjectRollingReleasesConfigOff(nameSuffix string) string { +func testAccProjectRollingReleasesConfigDisabled(nameSuffix string) string { return fmt.Sprintf(` resource "vercel_project" "example" { - name = "test-acc-example-project-%s" + name = "test-acc-rolling-releases-%s" } resource "vercel_project_rolling_release" "example" { project_id = vercel_project.example.id - depends_on = [vercel_project.example] rolling_release = { enabled = false advancement_type = "" From 9926350254db5a8b611d76cdb121749b7cb7f371 Mon Sep 17 00:00:00 2001 From: Douglas Harcourt Parsons Date: Fri, 13 Jun 2025 21:30:31 +0100 Subject: [PATCH 46/70] More fixes --- client/project_rolling_release.go | 6 ++++++ vercel/resource_project_rolling_release.go | 20 +++++++++++--------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/client/project_rolling_release.go b/client/project_rolling_release.go index 145e90c3..d9fd04cc 100644 --- a/client/project_rolling_release.go +++ b/client/project_rolling_release.go @@ -3,6 +3,8 @@ package client import ( "context" "fmt" + + "github.com/hashicorp/terraform-plugin-log/tflog" ) // RollingReleaseStage represents a stage in a rolling release @@ -78,6 +80,10 @@ func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRolling 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 diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go index a27ac534..943063e5 100644 --- a/vercel/resource_project_rolling_release.go +++ b/vercel/resource_project_rolling_release.go @@ -3,7 +3,6 @@ package vercel import ( "context" "fmt" - "time" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework/attr" @@ -101,6 +100,7 @@ func (r *projectRollingReleaseResource) Schema(ctx context.Context, _ resource.S "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, + Computed: true, }, "require_approval": schema.BoolAttribute{ MarkdownDescription: "Whether approval is required before advancing to the next stage.", @@ -195,7 +195,7 @@ func (e *RollingReleaseInfo) toUpdateRollingReleaseRequest() (client.UpdateRolli } // Log the request for debugging - tflog.Debug(context.Background(), "converting to update request", map[string]any{ + tflog.Info(context.Background(), "converting to update request", map[string]any{ "enabled": e.RollingRelease.Enabled.ValueBool(), "advancement_type": advancementType, "stages_count": len(stages), @@ -214,17 +214,21 @@ func (e *RollingReleaseInfo) toUpdateRollingReleaseRequest() (client.UpdateRolli 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: types.BoolValue(response.RollingRelease.Enabled), - AdvancementType: types.StringValue(response.RollingRelease.AdvancementType), + Enabled: plan.RollingRelease.Enabled, + AdvancementType: advancementType, }, ProjectID: types.StringValue(response.ProjectID), TeamID: types.StringValue(response.TeamID), } // If disabled, return empty values - if !response.RollingRelease.Enabled { + if !plan.RollingRelease.Enabled.ValueBool() { result.RollingRelease.AdvancementType = types.StringValue("") // Create an empty list instead of null emptyStages, stagesDiags := types.ListValueFrom(ctx, types.ObjectType{ @@ -293,7 +297,7 @@ func convertResponseToRollingRelease(response client.RollingReleaseInfo, plan *R } } else { // For manual approval, duration is not used - duration = types.Int64Value(0) + duration = types.Int64Null() } elements[i] = types.ObjectValueMust( @@ -324,7 +328,7 @@ func convertResponseToRollingRelease(response client.RollingReleaseInfo, plan *R result.RollingRelease.Stages = stages // Log the conversion result for debugging - tflog.Debug(ctx, "converted rolling release response", map[string]any{ + tflog.Info(ctx, "converted rolling release response", map[string]any{ "enabled": result.RollingRelease.Enabled.ValueBool(), "advancement_type": result.RollingRelease.AdvancementType.ValueString(), "stages_count": len(elements), @@ -379,8 +383,6 @@ func (r *projectRollingReleaseResource) Create(ctx context.Context, req resource ) return } - - time.Sleep(2 * time.Second) } out, err := r.client.UpdateRollingRelease(ctx, request) From afbba0f321a90ed64e3f7df5811261215da5f8b6 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 13 Jun 2025 14:37:06 -0600 Subject: [PATCH 47/70] Update data_source_project_rolling_release_test.go --- vercel/data_source_project_rolling_release_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/vercel/data_source_project_rolling_release_test.go b/vercel/data_source_project_rolling_release_test.go index 33fc588d..29c6e7e6 100644 --- a/vercel/data_source_project_rolling_release_test.go +++ b/vercel/data_source_project_rolling_release_test.go @@ -9,7 +9,6 @@ import ( ) func TestAcc_ProjectRollingReleaseDataSource(t *testing.T) { - return nameSuffix := acctest.RandString(16) resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, From 6acb266930d5a583a725c12e505500848fd36b15 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 13 Jun 2025 14:52:16 -0600 Subject: [PATCH 48/70] [rr] --- vercel/resource_project_rolling_release_test.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/vercel/resource_project_rolling_release_test.go b/vercel/resource_project_rolling_release_test.go index b11d0f3d..f1a31d25 100644 --- a/vercel/resource_project_rolling_release_test.go +++ b/vercel/resource_project_rolling_release_test.go @@ -53,7 +53,7 @@ func TestAcc_ProjectRollingRelease(t *testing.T) { "target_percentage": "50", }), resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ - "require_approval": "true", + "require_approval": "false", "target_percentage": "100", }), ), @@ -113,15 +113,12 @@ resource "vercel_project_rolling_release" "example" { advancement_type = "manual-approval" stages = [ { - require_approval = true target_percentage = 20 }, { - require_approval = true target_percentage = 50 }, { - require_approval = true target_percentage = 100 } ] From 08370ecdbfd71b27d365c0f6bb357365f6e4df1a Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 13 Jun 2025 15:05:29 -0600 Subject: [PATCH 49/70] [rr] --- vercel/resource_project_rolling_release_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/vercel/resource_project_rolling_release_test.go b/vercel/resource_project_rolling_release_test.go index f1a31d25..81bda2d6 100644 --- a/vercel/resource_project_rolling_release_test.go +++ b/vercel/resource_project_rolling_release_test.go @@ -104,6 +104,7 @@ 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" { From 5ec8a67f19e402a4d1386c7eeb8e4ff01e2c8f23 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 13 Jun 2025 15:17:41 -0600 Subject: [PATCH 50/70] [rr] --- vercel/resource_project_rolling_release_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vercel/resource_project_rolling_release_test.go b/vercel/resource_project_rolling_release_test.go index 81bda2d6..b1d47e2a 100644 --- a/vercel/resource_project_rolling_release_test.go +++ b/vercel/resource_project_rolling_release_test.go @@ -80,7 +80,7 @@ func TestAcc_ProjectRollingRelease(t *testing.T) { "target_percentage": "80", }), resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ - "require_approval": "true", + "require_approval": "false", "target_percentage": "100", }), ), From 1562ef526c91deedab6c32c69b29d61beef5919d Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 13 Jun 2025 15:35:20 -0600 Subject: [PATCH 51/70] [rr] --- vercel/resource_project_rolling_release_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vercel/resource_project_rolling_release_test.go b/vercel/resource_project_rolling_release_test.go index b1d47e2a..76703618 100644 --- a/vercel/resource_project_rolling_release_test.go +++ b/vercel/resource_project_rolling_release_test.go @@ -153,7 +153,7 @@ resource "vercel_project_rolling_release" "example" { target_percentage = 80 }, { - require_approval = true + require_approval = false target_percentage = 100 } ] From ed0fef6f5ade361927ea0227fc334e174b8bb3a7 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 13 Jun 2025 15:56:25 -0600 Subject: [PATCH 52/70] [rr] --- vercel/resource_project_rolling_release_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/vercel/resource_project_rolling_release_test.go b/vercel/resource_project_rolling_release_test.go index 76703618..7c1d3d90 100644 --- a/vercel/resource_project_rolling_release_test.go +++ b/vercel/resource_project_rolling_release_test.go @@ -132,6 +132,7 @@ 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" { From 3e90d29c667d52367a016ba3b7e42540a1c0f239 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 13 Jun 2025 16:15:36 -0600 Subject: [PATCH 53/70] Update examples/resources/vercel_project_rolling_release/resource.tf Co-authored-by: Douglas Harcourt Parsons --- examples/resources/vercel_project_rolling_release/resource.tf | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/resources/vercel_project_rolling_release/resource.tf b/examples/resources/vercel_project_rolling_release/resource.tf index cb2c2654..e895ed1f 100644 --- a/examples/resources/vercel_project_rolling_release/resource.tf +++ b/examples/resources/vercel_project_rolling_release/resource.tf @@ -5,7 +5,6 @@ resource "vercel_project" "example" { resource "vercel_project_rolling_release" "example" { project_id = vercel_project.example.id - depends_on = [vercel_project.example] rolling_release = { enabled = true advancement_type = "manual-approval" From d58f6e3c58e7716f1c58376baddfb11ac7f79fce Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 13 Jun 2025 16:18:33 -0600 Subject: [PATCH 54/70] [rr] --- docs/resources/project_rolling_release.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/resources/project_rolling_release.md b/docs/resources/project_rolling_release.md index 409c8d42..684722b3 100644 --- a/docs/resources/project_rolling_release.md +++ b/docs/resources/project_rolling_release.md @@ -20,7 +20,6 @@ resource "vercel_project" "example" { resource "vercel_project_rolling_release" "example" { project_id = vercel_project.example.id - depends_on = [vercel_project.example] rolling_release = { enabled = true advancement_type = "manual-approval" From 9d249acbd87d5ce550ad943cb53afd25dfc61183 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Fri, 13 Jun 2025 16:36:43 -0600 Subject: [PATCH 55/70] Update project.go --- client/project.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/project.go b/client/project.go index 0d48cbc2..2433b261 100644 --- a/client/project.go +++ b/client/project.go @@ -210,7 +210,7 @@ type ProjectResponse struct { ResourceConfig *ResourceConfigResponse `json:"resourceConfig"` NodeVersion string `json:"nodeVersion"` Crons *ProjectCronsResponse `json:"crons"` - RollingRelease *RollingRelease `json:"rollingRelease,omitempty"` + RollingRelease *RollingRelease `json:"rollingRelease"` } type ProjectCronsResponse struct { From 65ba997c44bf519bab82b6733b1acd98cffc8bf6 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Tue, 17 Jun 2025 09:11:36 -0600 Subject: [PATCH 56/70] [rolling-release] --- vercel/resource_project_rolling_release.go | 1 - 1 file changed, 1 deletion(-) diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go index 943063e5..3e3a1997 100644 --- a/vercel/resource_project_rolling_release.go +++ b/vercel/resource_project_rolling_release.go @@ -104,7 +104,6 @@ func (r *projectRollingReleaseResource) Schema(ctx context.Context, _ resource.S }, "require_approval": schema.BoolAttribute{ MarkdownDescription: "Whether approval is required before advancing to the next stage.", - Optional: true, Computed: true, }, }, From 305ca2b71cc4bc9909f1ac69907e634f0a2693a2 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Tue, 17 Jun 2025 09:14:12 -0600 Subject: [PATCH 57/70] [rolling-release] --- docs/resources/project_rolling_release.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/resources/project_rolling_release.md b/docs/resources/project_rolling_release.md index 684722b3..f22067aa 100644 --- a/docs/resources/project_rolling_release.md +++ b/docs/resources/project_rolling_release.md @@ -75,4 +75,7 @@ Required: 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. From edead8fd07896ad0eb5bf1b8b1b852095b147bf3 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Tue, 17 Jun 2025 09:26:54 -0600 Subject: [PATCH 58/70] [rolling-release] --- vercel/resource_project_rolling_release_test.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/vercel/resource_project_rolling_release_test.go b/vercel/resource_project_rolling_release_test.go index 7c1d3d90..0b08404a 100644 --- a/vercel/resource_project_rolling_release_test.go +++ b/vercel/resource_project_rolling_release_test.go @@ -142,19 +142,15 @@ resource "vercel_project_rolling_release" "example" { advancement_type = "manual-approval" stages = [ { - require_approval = true target_percentage = 20 }, { - require_approval = true target_percentage = 50 }, { - require_approval = true target_percentage = 80 }, { - require_approval = false target_percentage = 100 } ] From 13c89646a720d70235183c6f06af94ceba6050f8 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Tue, 17 Jun 2025 10:08:27 -0600 Subject: [PATCH 59/70] [rolling-release] --- docs/resources/project_rolling_release.md | 6 +- vercel/resource_project_rolling_release.go | 8 ++- .../resource_project_rolling_release_test.go | 59 +++++++++++++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/docs/resources/project_rolling_release.md b/docs/resources/project_rolling_release.md index f22067aa..ae31c717 100644 --- a/docs/resources/project_rolling_release.md +++ b/docs/resources/project_rolling_release.md @@ -63,6 +63,7 @@ Required: Optional: - `advancement_type` (String) The type of advancement between stages. Must be either 'automatic' or 'manual-approval'. Required when enabled is true. +- `duration` (Number) The duration in minutes to wait before advancing to the next stage. - `stages` (Attributes List) The stages of the rolling release. Required when enabled is true. (see [below for nested schema](#nestedatt--rolling_release--stages)) @@ -72,10 +73,7 @@ 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 all stages except the final stage when using automatic advancement. - Read-Only: +- `duration` (Number) The duration in minutes to wait before advancing to the next stage. - `require_approval` (Boolean) Whether approval is required before advancing to the next stage. diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go index 3e3a1997..a04ce974 100644 --- a/vercel/resource_project_rolling_release.go +++ b/vercel/resource_project_rolling_release.go @@ -84,6 +84,11 @@ func (r *projectRollingReleaseResource) Schema(ctx context.Context, _ resource.S advancementTypeValidator{}, }, }, + "duration": schema.Int64Attribute{ + MarkdownDescription: "The duration in minutes to wait before advancing to the next stage.", + Optional: true, + Computed: true, + }, "stages": schema.ListNestedAttribute{ MarkdownDescription: "The stages of the rolling release. Required when enabled is true.", Optional: true, @@ -98,8 +103,7 @@ func (r *projectRollingReleaseResource) Schema(ctx context.Context, _ resource.S }, }, "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, + MarkdownDescription: "The duration in minutes to wait before advancing to the next stage.", Computed: true, }, "require_approval": schema.BoolAttribute{ diff --git a/vercel/resource_project_rolling_release_test.go b/vercel/resource_project_rolling_release_test.go index 0b08404a..737a8bf7 100644 --- a/vercel/resource_project_rolling_release_test.go +++ b/vercel/resource_project_rolling_release_test.go @@ -85,6 +85,34 @@ func TestAcc_ProjectRollingRelease(t *testing.T) { }), ), }, + // 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.duration", "10"), + resource.TestCheckResourceAttr(resourceName, "rolling_release.stages.#", "4"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ + "require_approval": "false", + "target_percentage": "20", + }), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ + "require_approval": "false", + "target_percentage": "50", + }), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ + "require_approval": "false", + "target_percentage": "80", + }), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ + "require_approval": "false", + "target_percentage": "100", + }), + ), + }, // Finally disable { Config: cfg(testAccProjectRollingReleasesConfigDisabled(nameSuffix)), @@ -158,6 +186,37 @@ resource "vercel_project_rolling_release" "example" { } `, 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" + duration = 10 + stages = [ + { + target_percentage = 20 + }, + { + target_percentage = 50 + }, + { + target_percentage = 80 + }, + { + target_percentage = 100 + } + ] + } +} +`, nameSuffix) +} func testAccProjectRollingReleasesConfigDisabled(nameSuffix string) string { return fmt.Sprintf(` From 802c9a3c77327f4a1c6e69e2542868ec8747b0a2 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Tue, 17 Jun 2025 10:30:39 -0600 Subject: [PATCH 60/70] [rolling-release] --- vercel/resource_project_rolling_release.go | 23 +++++++--------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go index a04ce974..77125b20 100644 --- a/vercel/resource_project_rolling_release.go +++ b/vercel/resource_project_rolling_release.go @@ -85,7 +85,7 @@ func (r *projectRollingReleaseResource) Schema(ctx context.Context, _ resource.S }, }, "duration": schema.Int64Attribute{ - MarkdownDescription: "The duration in minutes to wait before advancing to the next stage.", + MarkdownDescription: "The duration in minutes to wait before advancing to the next stage. This duration will be applied to all stages except the last one.", Optional: true, Computed: true, }, @@ -129,6 +129,7 @@ type RollingReleaseStage struct { type RollingRelease struct { Enabled types.Bool `tfsdk:"enabled"` AdvancementType types.String `tfsdk:"advancement_type"` + Duration types.Int64 `tfsdk:"duration"` Stages types.List `tfsdk:"stages"` } @@ -160,21 +161,11 @@ func (e *RollingReleaseInfo) toUpdateRollingReleaseRequest() (client.UpdateRolli // 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(), - } + duration := int(e.RollingRelease.Duration.ValueInt64()) + stages[i] = client.RollingReleaseStage{ + TargetPercentage: int(stage.TargetPercentage.ValueInt64()), + Duration: &duration, + RequireApproval: stage.RequireApproval.ValueBool(), } } else { // Last stage should not have duration From 2bd7a505e863c82f923f1c1afc4aa9fad7201b09 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Tue, 17 Jun 2025 10:32:12 -0600 Subject: [PATCH 61/70] [rolling-release] --- vercel/resource_project_rolling_release.go | 1 - vercel/resource_project_rolling_release_test.go | 6 ++++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go index 77125b20..b3a0aa92 100644 --- a/vercel/resource_project_rolling_release.go +++ b/vercel/resource_project_rolling_release.go @@ -129,7 +129,6 @@ type RollingReleaseStage struct { type RollingRelease struct { Enabled types.Bool `tfsdk:"enabled"` AdvancementType types.String `tfsdk:"advancement_type"` - Duration types.Int64 `tfsdk:"duration"` Stages types.List `tfsdk:"stages"` } diff --git a/vercel/resource_project_rolling_release_test.go b/vercel/resource_project_rolling_release_test.go index 737a8bf7..cd56b5ab 100644 --- a/vercel/resource_project_rolling_release_test.go +++ b/vercel/resource_project_rolling_release_test.go @@ -93,7 +93,6 @@ func TestAcc_ProjectRollingRelease(t *testing.T) { 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.duration", "10"), resource.TestCheckResourceAttr(resourceName, "rolling_release.stages.#", "4"), resource.TestCheckTypeSetElemNestedAttrs(resourceName, "rolling_release.stages.*", map[string]string{ "require_approval": "false", @@ -198,19 +197,22 @@ resource "vercel_project_rolling_release" "example" { rolling_release = { enabled = true advancement_type = "automatic" - duration = 10 stages = [ { target_percentage = 20 + duration = 10 }, { target_percentage = 50 + duration = 10 }, { target_percentage = 80 + duration = 10 }, { target_percentage = 100 + duration = 10 } ] } From f7313ee79d6cee3d4f35157ba78353d38b992353 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Tue, 17 Jun 2025 10:33:49 -0600 Subject: [PATCH 62/70] [rolling-release] --- vercel/resource_project_rolling_release.go | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go index b3a0aa92..019563dc 100644 --- a/vercel/resource_project_rolling_release.go +++ b/vercel/resource_project_rolling_release.go @@ -160,11 +160,21 @@ func (e *RollingReleaseInfo) toUpdateRollingReleaseRequest() (client.UpdateRolli // For automatic advancement, duration is required except for last stage if i < len(tfStages)-1 { // Non-last stage needs duration - duration := int(e.RollingRelease.Duration.ValueInt64()) - stages[i] = client.RollingReleaseStage{ - TargetPercentage: int(stage.TargetPercentage.ValueInt64()), - Duration: &duration, - RequireApproval: stage.RequireApproval.ValueBool(), + 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 From f4a07cb1b5c3f91890ca8566c4ff27908bff21ec Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Tue, 17 Jun 2025 11:20:45 -0600 Subject: [PATCH 63/70] [rolling-release] --- docs/resources/project_rolling_release.md | 1 - vercel/resource_project_rolling_release.go | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/docs/resources/project_rolling_release.md b/docs/resources/project_rolling_release.md index ae31c717..73064021 100644 --- a/docs/resources/project_rolling_release.md +++ b/docs/resources/project_rolling_release.md @@ -63,7 +63,6 @@ Required: Optional: - `advancement_type` (String) The type of advancement between stages. Must be either 'automatic' or 'manual-approval'. Required when enabled is true. -- `duration` (Number) The duration in minutes to wait before advancing to the next stage. - `stages` (Attributes List) The stages of the rolling release. Required when enabled is true. (see [below for nested schema](#nestedatt--rolling_release--stages)) diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go index 019563dc..e7a31397 100644 --- a/vercel/resource_project_rolling_release.go +++ b/vercel/resource_project_rolling_release.go @@ -84,11 +84,6 @@ func (r *projectRollingReleaseResource) Schema(ctx context.Context, _ resource.S advancementTypeValidator{}, }, }, - "duration": schema.Int64Attribute{ - MarkdownDescription: "The duration in minutes to wait before advancing to the next stage. This duration will be applied to all stages except the last one.", - Optional: true, - Computed: true, - }, "stages": schema.ListNestedAttribute{ MarkdownDescription: "The stages of the rolling release. Required when enabled is true.", Optional: true, @@ -129,6 +124,7 @@ type RollingReleaseStage struct { type RollingRelease struct { Enabled types.Bool `tfsdk:"enabled"` AdvancementType types.String `tfsdk:"advancement_type"` + Duration types.Int64 `tfsdk:"duration"` Stages types.List `tfsdk:"stages"` } From 8d196cee121c27dc0bd69d35eedeb92ab7dfda8b Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Tue, 17 Jun 2025 11:39:18 -0600 Subject: [PATCH 64/70] [rolling-release] --- docs/resources/project_rolling_release.md | 5 ++++- vercel/resource_project_rolling_release.go | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/resources/project_rolling_release.md b/docs/resources/project_rolling_release.md index 73064021..8a25327c 100644 --- a/docs/resources/project_rolling_release.md +++ b/docs/resources/project_rolling_release.md @@ -72,7 +72,10 @@ Required: - `target_percentage` (Number) The percentage of traffic to route to this stage. -Read-Only: +Optional: - `duration` (Number) The duration in minutes to wait before advancing to the next stage. + +Read-Only: + - `require_approval` (Boolean) Whether approval is required before advancing to the next stage. diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go index e7a31397..a8676768 100644 --- a/vercel/resource_project_rolling_release.go +++ b/vercel/resource_project_rolling_release.go @@ -100,6 +100,7 @@ func (r *projectRollingReleaseResource) Schema(ctx context.Context, _ resource.S "duration": schema.Int64Attribute{ MarkdownDescription: "The duration in minutes to wait before advancing to the next stage.", Computed: true, + Optional: true, }, "require_approval": schema.BoolAttribute{ MarkdownDescription: "Whether approval is required before advancing to the next stage.", From 81d7ca8c0a273900fbdbfb4bae1f38d277a97a02 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Tue, 17 Jun 2025 11:55:32 -0600 Subject: [PATCH 65/70] [rolling-release] --- vercel/resource_project_rolling_release.go | 1 - 1 file changed, 1 deletion(-) diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go index a8676768..1ae85448 100644 --- a/vercel/resource_project_rolling_release.go +++ b/vercel/resource_project_rolling_release.go @@ -125,7 +125,6 @@ type RollingReleaseStage struct { type RollingRelease struct { Enabled types.Bool `tfsdk:"enabled"` AdvancementType types.String `tfsdk:"advancement_type"` - Duration types.Int64 `tfsdk:"duration"` Stages types.List `tfsdk:"stages"` } From 3c6533402930da523461709c299c4806166b94b9 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Tue, 17 Jun 2025 12:24:34 -0600 Subject: [PATCH 66/70] [rolling-release] --- vercel/resource_project_rolling_release_test.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/vercel/resource_project_rolling_release_test.go b/vercel/resource_project_rolling_release_test.go index cd56b5ab..81dd9557 100644 --- a/vercel/resource_project_rolling_release_test.go +++ b/vercel/resource_project_rolling_release_test.go @@ -97,14 +97,17 @@ func TestAcc_ProjectRollingRelease(t *testing.T) { 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", @@ -200,19 +203,18 @@ resource "vercel_project_rolling_release" "example" { stages = [ { target_percentage = 20 - duration = 10 + duration = 10 }, { target_percentage = 50 - duration = 10 + duration = 10 }, { target_percentage = 80 - duration = 10 + duration = 10 }, { target_percentage = 100 - duration = 10 } ] } From 2f401da580b28449efddf29867601bc7d039b6b6 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Tue, 17 Jun 2025 12:40:10 -0600 Subject: [PATCH 67/70] [rolling-release] --- vercel/data_source_project_rolling_release.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vercel/data_source_project_rolling_release.go b/vercel/data_source_project_rolling_release.go index f39de818..751b1255 100644 --- a/vercel/data_source_project_rolling_release.go +++ b/vercel/data_source_project_rolling_release.go @@ -83,7 +83,7 @@ func (d *projectRollingReleaseDataSource) Schema(ctx context.Context, _ datasour }, "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.", - Computed: true, + Optional: true, }, "require_approval": schema.BoolAttribute{ MarkdownDescription: "Whether approval is required before advancing to the next stage.", From ad6201f686cc69afdbd465950c9a28747649dfcd Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Tue, 17 Jun 2025 12:40:23 -0600 Subject: [PATCH 68/70] [rolling-release] --- docs/data-sources/project_rolling_release.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/data-sources/project_rolling_release.md b/docs/data-sources/project_rolling_release.md index 65bdb845..959d02db 100644 --- a/docs/data-sources/project_rolling_release.md +++ b/docs/data-sources/project_rolling_release.md @@ -46,8 +46,11 @@ Read-Only: ### Nested Schema for `rolling_release.stages` -Read-Only: +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. From 0d24b077b848ed61d5405255d142257f357f71fb Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Tue, 17 Jun 2025 12:59:50 -0600 Subject: [PATCH 69/70] [rolling-release] --- vercel/resource_project_rolling_release.go | 80 ++++++++++++---------- 1 file changed, 45 insertions(+), 35 deletions(-) diff --git a/vercel/resource_project_rolling_release.go b/vercel/resource_project_rolling_release.go index 1ae85448..704c4366 100644 --- a/vercel/resource_project_rolling_release.go +++ b/vercel/resource_project_rolling_release.go @@ -98,9 +98,11 @@ func (r *projectRollingReleaseResource) Schema(ctx context.Context, _ resource.S }, }, "duration": schema.Int64Attribute{ - MarkdownDescription: "The duration in minutes to wait before advancing to the next stage.", - Computed: true, + 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.", @@ -180,7 +182,7 @@ func (e *RollingReleaseInfo) toUpdateRollingReleaseRequest() (client.UpdateRolli } } } else { - // For manual approval, omit duration field completely + // For manual approval, duration is not used stages[i] = client.RollingReleaseStage{ TargetPercentage: int(stage.TargetPercentage.ValueInt64()), RequireApproval: stage.RequireApproval.ValueBool(), @@ -247,9 +249,9 @@ func convertResponseToRollingRelease(response client.RollingReleaseInfo, plan *R // 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() && !plan.RollingRelease.Stages.IsUnknown() { + if plan != nil && !plan.RollingRelease.Stages.IsNull() { var planStages []RollingReleaseStage - diags.Append(plan.RollingRelease.Stages.ElementsAs(ctx, &planStages, false)...) + diags = plan.RollingRelease.Stages.ElementsAs(ctx, &planStages, false) if diags.HasError() { return result, diags } @@ -260,46 +262,53 @@ func convertResponseToRollingRelease(response client.RollingReleaseInfo, plan *R stageMap[stage.TargetPercentage] = stage } - // Try to preserve the order from the plan - orderedStages = make([]client.RollingReleaseStage, 0, len(response.RollingRelease.Stages)) - for _, planStage := range planStages { - if stage, ok := stageMap[int(planStage.TargetPercentage.ValueInt64())]; ok { - orderedStages = append(orderedStages, stage) - delete(stageMap, stage.TargetPercentage) - } - } - - // Add any remaining stages that weren't in the plan - for _, stage := range response.RollingRelease.Stages { - if _, ok := stageMap[stage.TargetPercentage]; ok { - orderedStages = append(orderedStages, 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 from response - elements := make([]attr.Value, len(orderedStages)) + // Convert stages to Terraform types + stages := make([]attr.Value, len(orderedStages)) for i, stage := range orderedStages { var duration types.Int64 - if response.RollingRelease.AdvancementType == "automatic" { - // For automatic advancement, duration is required except for the last stage - if i < len(orderedStages)-1 { - if stage.Duration != nil { - duration = types.Int64Value(int64(*stage.Duration)) + 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 { - duration = types.Int64Value(60) // Default duration in minutes + // Last stage doesn't need duration + duration = types.Int64Null() } } else { - duration = types.Int64Value(0) // Last stage doesn't need duration + // For manual approval, duration is not used + duration = types.Int64Null() } - } else { - // For manual approval, duration is not used - duration = types.Int64Null() } - elements[i] = types.ObjectValueMust( + stageObj := types.ObjectValueMust( map[string]attr.Type{ "target_percentage": types.Int64Type, "duration": types.Int64Type, @@ -311,26 +320,27 @@ func convertResponseToRollingRelease(response client.RollingReleaseInfo, plan *R "require_approval": types.BoolValue(stage.RequireApproval), }, ) + stages[i] = stageObj } - stages, stagesDiags := types.ListValueFrom(ctx, types.ObjectType{ + stagesList, stagesDiags := types.ListValueFrom(ctx, types.ObjectType{ AttrTypes: map[string]attr.Type{ "target_percentage": types.Int64Type, "duration": types.Int64Type, "require_approval": types.BoolType, }, - }, elements) + }, stages) diags.Append(stagesDiags...) if diags.HasError() { return result, diags } - result.RollingRelease.Stages = stages + 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(elements), + "stages_count": len(stages), }) return result, diags From 687becdd80bc7686d950716a0b916901e093dc9c Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Tue, 17 Jun 2025 13:00:05 -0600 Subject: [PATCH 70/70] [rolling-release] --- docs/resources/project_rolling_release.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/resources/project_rolling_release.md b/docs/resources/project_rolling_release.md index 8a25327c..8eb1142e 100644 --- a/docs/resources/project_rolling_release.md +++ b/docs/resources/project_rolling_release.md @@ -74,7 +74,7 @@ Required: Optional: -- `duration` (Number) The duration in minutes to wait before advancing to the next stage. +- `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: