diff --git a/client/project.go b/client/project.go index 9c84a9be..0d48cbc2 100644 --- a/client/project.go +++ b/client/project.go @@ -210,6 +210,7 @@ type ProjectResponse struct { ResourceConfig *ResourceConfigResponse `json:"resourceConfig"` NodeVersion string `json:"nodeVersion"` Crons *ProjectCronsResponse `json:"crons"` + RollingRelease *RollingRelease `json:"rollingRelease,omitempty"` } type ProjectCronsResponse struct { diff --git a/client/project_rolling_release.go b/client/project_rolling_release.go new file mode 100644 index 00000000..6662a70d --- /dev/null +++ b/client/project_rolling_release.go @@ -0,0 +1,271 @@ +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 int `json:"targetPercentage"` // Required: 0-100 + Duration *int `json:"duration,omitempty"` // Required for automatic advancement: 1-10000 minutes + RequireApproval bool `json:"requireApproval,omitempty"` // Only in response for manual-approval type +} + +// RollingRelease represents the rolling release configuration +type RollingRelease struct { + Enabled bool `json:"enabled"` // Required + AdvancementType string `json:"advancementType"` // Required when enabled=true: 'automatic' or 'manual-approval' + Stages []RollingReleaseStage `json:"stages"` // Required when enabled=true: 2-10 stages +} + +// 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"` + ProjectID string `json:"projectId"` + TeamID string `json:"teamId"` +} + +// GetRollingRelease returns the rolling release for a given project. +func (c *Client) GetRollingRelease(ctx context.Context, projectID, teamID string) (RollingReleaseInfo, error) { + url := fmt.Sprintf("%s/v1/projects/%s/rolling-release/config?teamId=%s", c.baseURL, projectID, teamID) + + tflog.Debug(ctx, "getting rolling-release configuration", map[string]any{ + "url": url, + "method": "GET", + "project_id": projectID, + "team_id": teamID, + }) + + d := RollingReleaseInfo{} + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "GET", + url: url, + }, &d) + d.ProjectID = projectID + d.TeamID = teamID + return d, err +} + +// UpdateRollingReleaseRequest defines the information that needs to be passed to Vercel in order to +// update a rolling release. +type UpdateRollingReleaseRequest struct { + RollingRelease RollingRelease `json:"rollingRelease"` + ProjectID string `json:"projectId"` + TeamID string `json:"teamId"` +} + +// UpdateRollingRelease will update an existing rolling release to the latest information. +func (c *Client) UpdateRollingRelease(ctx context.Context, request UpdateRollingReleaseRequest) (RollingReleaseInfo, error) { + // 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) + + // 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 + } + } + + // 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 + resp, err := c.doRequestWithResponse(clientRequest{ + ctx: ctx, + method: "PATCH", + url: url, + body: payload, + }) + + // 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.Debug(ctx, "deleting rolling-release configuration", map[string]any{ + "url": url, + "method": "DELETE", + "project_id": projectID, + "team_id": teamID, + }) + + var d RollingReleaseInfo + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "DELETE", + url: url, + }, &d) + d.ProjectID = projectID + d.TeamID = teamID + return err +} 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 new file mode 100644 index 00000000..93e99066 --- /dev/null +++ b/vercel/data_source_project_rolling_release.go @@ -0,0 +1,197 @@ +package vercel + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/vercel/terraform-provider-vercel/v3/client" +) + +var ( + _ datasource.DataSource = &projectRollingReleaseDataSource{} +) + +func newProjectRollingReleaseDataSource() datasource.DataSource { + return &projectRollingReleaseDataSource{} +} + +type projectRollingReleaseDataSource struct { + client *client.Client +} + +func (d *projectRollingReleaseDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_project_rolling_release" +} + +func (d *projectRollingReleaseDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + d.client = client +} + +func (d *projectRollingReleaseDataSource) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Data source for a Vercel project rolling release configuration.", + Attributes: map[string]schema.Attribute{ + "project_id": schema.StringAttribute{ + MarkdownDescription: "The ID of the project.", + Required: true, + }, + "team_id": schema.StringAttribute{ + 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 TFRollingReleaseStageDataSource struct { + TargetPercentage types.Int64 `tfsdk:"target_percentage"` + Duration types.Int64 `tfsdk:"duration"` + RequireApproval types.Bool `tfsdk:"require_approval"` +} + +type TFRollingReleaseDataSource struct { + Enabled types.Bool `tfsdk:"enabled"` + AdvancementType types.String `tfsdk:"advancement_type"` + Stages []TFRollingReleaseStageDataSource `tfsdk:"stages"` +} + +type 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 := d.client.GetRollingRelease(ctx, config.ProjectID.ValueString(), config.TeamID.ValueString()) + if client.NotFound(err) { + resp.Diagnostics.AddError( + "Error reading project rolling release", + fmt.Sprintf("No project rolling release found with id %s %s", config.TeamID.ValueString(), config.ProjectID.ValueString()), + ) + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error reading project rolling release", + fmt.Sprintf("Could not get project rolling release %s %s, unexpected error: %s", + config.TeamID.ValueString(), + config.ProjectID.ValueString(), + err, + ), + ) + return + } + + result := convertResponseToTFRollingReleaseDataSource(out) + tflog.Info(ctx, "read project rolling release", map[string]any{ + "team_id": result.TeamID.ValueString(), + "project_id": result.ProjectID.ValueString(), + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} diff --git a/vercel/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..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 + } +}