From 797b9cda8673e08e9033f017706c42430eb68dd9 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Tue, 3 Jun 2025 07:09:02 -0400 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 e33f3e7e243d70d78f7ad6764f5dc8319d97f9e2 Mon Sep 17 00:00:00 2001 From: Brooke Mosby Date: Thu, 5 Jun 2025 16:44:58 -0600 Subject: [PATCH 5/5] [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 - } -}