From 44a485ccda3d1e5eb403f8a3526cb63f16ef737f Mon Sep 17 00:00:00 2001 From: Douglas Harcourt Parsons Date: Mon, 26 May 2025 15:17:32 +0100 Subject: [PATCH 1/2] Create resource for `vercel_project_crons` --- client/project.go | 6 + client/project_crons.go | 51 ++++ docs/resources/project_crons.md | 60 ++++ .../resources/vercel_project_crons/import.sh | 8 + .../vercel_project_crons/resource.tf | 14 + vercel/provider.go | 3 +- vercel/resource_project_crons.go | 266 ++++++++++++++++++ vercel/resource_project_crons_test.go | 90 ++++++ 8 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 client/project_crons.go create mode 100644 docs/resources/project_crons.md create mode 100644 examples/resources/vercel_project_crons/import.sh create mode 100644 examples/resources/vercel_project_crons/resource.tf create mode 100644 vercel/resource_project_crons.go create mode 100644 vercel/resource_project_crons_test.go diff --git a/client/project.go b/client/project.go index 5b1cee76..9c84a9be 100644 --- a/client/project.go +++ b/client/project.go @@ -209,6 +209,12 @@ type ProjectResponse struct { DeploymentExpiration *DeploymentExpiration `json:"deploymentExpiration"` ResourceConfig *ResourceConfigResponse `json:"resourceConfig"` NodeVersion string `json:"nodeVersion"` + Crons *ProjectCronsResponse `json:"crons"` +} + +type ProjectCronsResponse struct { + DisabledAt *int `json:"disabledAt"` + EnabledAt int `json:"enabled"` } type GitComments struct { diff --git a/client/project_crons.go b/client/project_crons.go new file mode 100644 index 00000000..dc7d4100 --- /dev/null +++ b/client/project_crons.go @@ -0,0 +1,51 @@ +package client + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// ProjectCrons represents the crons settings for a Vercel project. +type ProjectCrons struct { + ProjectID string `json:"-"` + TeamID string `json:"-"` + Enabled bool `json:"enabled"` +} + +// GetProjectCrons retrieves the current crons status for a project. +func (c *Client) GetProjectCrons(ctx context.Context, projectID, teamID string) (ProjectCrons, error) { + r, err := c.GetProject(ctx, projectID, teamID) + + return ProjectCrons{ + ProjectID: projectID, + TeamID: teamID, + Enabled: r.Crons == nil || r.Crons.DisabledAt == nil, + }, err +} + +// UpdateProjectCrons toggles the crons feature for a project. +func (c *Client) UpdateProjectCrons(ctx context.Context, request ProjectCrons) (ProjectCrons, error) { + url := fmt.Sprintf("%s/v1/projects/%s/crons", c.baseURL, request.ProjectID) + if c.TeamID(request.TeamID) != "" { + url = fmt.Sprintf("%s?teamId=%s", url, c.TeamID(request.TeamID)) + } + tflog.Info(ctx, "updating project crons", map[string]any{ + "url": url, + "payload": request, + }) + var r ProjectResponse + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "PATCH", + url: url, + body: string(mustMarshal(request)), + }, &r) + + return ProjectCrons{ + ProjectID: request.ProjectID, + TeamID: request.TeamID, + Enabled: r.Crons == nil || r.Crons.DisabledAt == nil, + }, err +} diff --git a/docs/resources/project_crons.md b/docs/resources/project_crons.md new file mode 100644 index 00000000..a3cd899b --- /dev/null +++ b/docs/resources/project_crons.md @@ -0,0 +1,60 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_project_crons Resource - terraform-provider-vercel" +subcategory: "" +description: |- + Provides a Project Crons resource. + The resource toggles whether crons are enabled for a Vercel project. +--- + +# vercel_project_crons (Resource) + +Provides a Project Crons resource. + +The resource toggles whether crons are enabled for a Vercel project. + +## Example Usage + +```terraform +resource "vercel_project" "example" { + name = "example-project" + framework = "nextjs" + + git_repository = { + type = "github" + repo = "vercel/some-repo" + } +} + +resource "vercel_project_crons" "example" { + project_id = vercel_project.example.id + enabled = true +} +``` + + +## Schema + +### Required + +- `enabled` (Boolean) Whether crons are enabled for the project. +- `project_id` (String) The ID of the Project to toggle crons for. + +### Optional + +- `team_id` (String) The ID of the team the Project exists under. Required when configuring a team resource if a default team has not been set in the provider. + +## Import + +Import is supported using the following syntax: + +```shell +# If importing with a team configured on the provider, simply use the project ID. +# - project_id can be found in the project `settings` tab in the Vercel UI. +terraform import vercel_project_crons.example prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Alternatively, you can import via the team_id and project_id. +# - team_id can be found in the team `settings` tab in the Vercel UI. +# - project_id can be found in the project `settings` tab in the Vercel UI. +terraform import vercel_project_crons.example team_xxxxxxxxxxxxxxxxxxxxxxxx/prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` diff --git a/examples/resources/vercel_project_crons/import.sh b/examples/resources/vercel_project_crons/import.sh new file mode 100644 index 00000000..5af954b4 --- /dev/null +++ b/examples/resources/vercel_project_crons/import.sh @@ -0,0 +1,8 @@ +# If importing with a team configured on the provider, simply use the project ID. +# - project_id can be found in the project `settings` tab in the Vercel UI. +terraform import vercel_project_crons.example prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Alternatively, you can import via the team_id and project_id. +# - team_id can be found in the team `settings` tab in the Vercel UI. +# - project_id can be found in the project `settings` tab in the Vercel UI. +terraform import vercel_project_crons.example team_xxxxxxxxxxxxxxxxxxxxxxxx/prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/examples/resources/vercel_project_crons/resource.tf b/examples/resources/vercel_project_crons/resource.tf new file mode 100644 index 00000000..b9710d5a --- /dev/null +++ b/examples/resources/vercel_project_crons/resource.tf @@ -0,0 +1,14 @@ +resource "vercel_project" "example" { + name = "example-project" + framework = "nextjs" + + git_repository = { + type = "github" + repo = "vercel/some-repo" + } +} + +resource "vercel_project_crons" "example" { + project_id = vercel_project.example.id + enabled = true +} diff --git a/vercel/provider.go b/vercel/provider.go index ac97d9a7..075f1c24 100644 --- a/vercel/provider.go +++ b/vercel/provider.go @@ -69,7 +69,8 @@ func (p *vercelProvider) Resources(_ context.Context) []func() resource.Resource newLogDrainResource, newMicrofrontendGroupMembershipResource, newMicrofrontendGroupResource, - newProjectDeploymentRetentionResource, + newProjectDeploymentRetentionResource, + newProjectCronsResource, newProjectDomainResource, newProjectEnvironmentVariableResource, newProjectEnvironmentVariablesResource, diff --git a/vercel/resource_project_crons.go b/vercel/resource_project_crons.go new file mode 100644 index 00000000..bba466e5 --- /dev/null +++ b/vercel/resource_project_crons.go @@ -0,0 +1,266 @@ +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/resource/schema/planmodifier" + "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" +) + +// Compile-time assertions to ensure the implementation conforms to the expected interfaces. +var ( + _ resource.Resource = &projectCronsResource{} + _ resource.ResourceWithConfigure = &projectCronsResource{} + _ resource.ResourceWithImportState = &projectCronsResource{} +) + +func newProjectCronsResource() resource.Resource { + return &projectCronsResource{} +} + +type projectCronsResource struct { + client *client.Client +} + +func (r *projectCronsResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_project_crons" +} + +func (r *projectCronsResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + cli, 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 = cli +} + +func (r *projectCronsResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "\nProvides a Project Crons resource.\n\nThe resource toggles whether crons are enabled for a Vercel project.\n", + Attributes: map[string]schema.Attribute{ + "project_id": schema.StringAttribute{ + Description: "The ID of the Project to toggle crons for.", + Required: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "team_id": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The ID of the team the Project exists under. Required when configuring a team resource if a default team has not been set in the provider.", + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplaceIfConfigured(), stringplanmodifier.UseStateForUnknown()}, + }, + "enabled": schema.BoolAttribute{ + Required: true, + Description: "Whether crons are enabled for the project.", + }, + }, + } +} + +// ProjectCrons mirrors the Terraform state for the resource. +type ProjectCrons struct { + ProjectID types.String `tfsdk:"project_id"` + TeamID types.String `tfsdk:"team_id"` + Enabled types.Bool `tfsdk:"enabled"` +} + +// mapResponseToProjectCrons converts the API response into the internal ProjectCrons model. +func mapResponseToProjectCrons(out client.ProjectCrons) ProjectCrons { + return ProjectCrons{ + ProjectID: types.StringValue(out.ProjectID), + TeamID: toTeamID(out.TeamID), + Enabled: types.BoolValue(out.Enabled), + } +} + +func (r *projectCronsResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan ProjectCrons + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Ensure the project exists – this provides a friendly error message if the ID is wrong. + _, err := r.client.GetProject(ctx, plan.ProjectID.ValueString(), plan.TeamID.ValueString()) + if client.NotFound(err) { + resp.Diagnostics.AddError( + "Error creating project crons", + "Could not find project, please make sure both the project_id and team_id match the project and team you wish to configure.", + ) + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error creating project crons", + "Error reading project information, unexpected error: "+err.Error(), + ) + return + } + + out, err := r.client.UpdateProjectCrons(ctx, client.ProjectCrons{ + TeamID: plan.TeamID.ValueString(), + ProjectID: plan.ProjectID.ValueString(), + Enabled: plan.Enabled.ValueBool(), + }) + if err != nil { + resp.Diagnostics.AddError( + "Error creating project crons", + "Could not create project crons, unexpected error: "+err.Error(), + ) + return + } + + result := mapResponseToProjectCrons(out) + tflog.Info(ctx, "created project crons", map[string]any{ + "team_id": result.TeamID.ValueString(), + "project_id": result.ProjectID.ValueString(), + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) +} + +func (r *projectCronsResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state ProjectCrons + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.client.GetProjectCrons(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 crons", + fmt.Sprintf("Could not get project crons %s %s, unexpected error: %s", state.TeamID.ValueString(), state.ProjectID.ValueString(), err), + ) + return + } + + result := mapResponseToProjectCrons(out) + tflog.Info(ctx, "read project crons", map[string]any{ + "team_id": result.TeamID.ValueString(), + "project_id": result.ProjectID.ValueString(), + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) +} + +func (r *projectCronsResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan ProjectCrons + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.client.UpdateProjectCrons(ctx, client.ProjectCrons{ + TeamID: plan.TeamID.ValueString(), + ProjectID: plan.ProjectID.ValueString(), + Enabled: plan.Enabled.ValueBool(), + }) + if client.NotFound(err) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error updating project crons", + fmt.Sprintf("Could not update project crons %s %s, unexpected error: %s", plan.TeamID.ValueString(), plan.ProjectID.ValueString(), err), + ) + return + } + + result := mapResponseToProjectCrons(out) + tflog.Trace(ctx, "updated project crons", map[string]any{ + "team_id": result.TeamID.ValueString(), + "project_id": result.ProjectID.ValueString(), + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) +} + +func (r *projectCronsResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state ProjectCrons + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Disable crons on deletion to align with existing boolean-toggle resources (e.g. attack_challenge_mode). + _, err := r.client.UpdateProjectCrons(ctx, client.ProjectCrons{ + TeamID: state.TeamID.ValueString(), + ProjectID: state.ProjectID.ValueString(), + Enabled: false, + }) + if client.NotFound(err) { + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error deleting project crons", + fmt.Sprintf("Could not delete project crons %s %s, unexpected error: %s", state.TeamID.ValueString(), state.ProjectID.ValueString(), err), + ) + return + } + + tflog.Info(ctx, "deleted project crons", map[string]any{ + "team_id": state.TeamID.ValueString(), + "project_id": state.ProjectID.ValueString(), + }) +} + +func (r *projectCronsResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + teamID, projectID, ok := splitInto1Or2(req.ID) + if !ok { + resp.Diagnostics.AddError( + "Error importing project crons", + fmt.Sprintf("Invalid id '%s' specified. should be in format \"team_id/project_id\" or \"project_id\"", req.ID), + ) + return + } + + out, err := r.client.GetProjectCrons(ctx, projectID, teamID) + if client.NotFound(err) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error reading project crons", + fmt.Sprintf("Could not get project crons %s %s, unexpected error: %s", teamID, projectID, err), + ) + return + } + + result := mapResponseToProjectCrons(out) + tflog.Info(ctx, "imported project crons", map[string]any{ + "team_id": result.TeamID.ValueString(), + "project_id": result.ProjectID.ValueString(), + }) + + diags := resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) +} diff --git a/vercel/resource_project_crons_test.go b/vercel/resource_project_crons_test.go new file mode 100644 index 00000000..306c0cf4 --- /dev/null +++ b/vercel/resource_project_crons_test.go @@ -0,0 +1,90 @@ +package vercel_test + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/vercel/terraform-provider-vercel/v3/client" +) + +func testAccProjectCronsExists(testClient *client.Client, n, teamID string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("no ID is set") + } + + _, err := testClient.GetProjectCrons(context.TODO(), rs.Primary.Attributes["project_id"], teamID) + return err + } +} + +func TestAcc_ProjectCrons(t *testing.T) { + nameSuffix := acctest.RandString(16) + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: resource.ComposeAggregateTestCheckFunc( + testAccProjectDestroy(testClient(t), "vercel_project.example", testTeam(t)), + ), + Steps: []resource.TestStep{ + { + Config: cfg(testAccProjectCronsConfig(nameSuffix, testGithubRepo(t))), + Check: resource.ComposeAggregateTestCheckFunc( + testAccProjectCronsExists(testClient(t), "vercel_project_crons.example", testTeam(t)), + resource.TestCheckResourceAttr("vercel_project_crons.example", "enabled", "false"), + ), + }, + { + Config: cfg(testAccProjectCronsConfigUpdated(nameSuffix, testGithubRepo(t))), + Check: resource.ComposeAggregateTestCheckFunc( + testAccProjectCronsExists(testClient(t), "vercel_project_crons.example", testTeam(t)), + resource.TestCheckResourceAttr("vercel_project_crons.example", "enabled", "true"), + ), + }, + }, + }) +} + +func testAccProjectCronsConfig(projectName string, githubRepo string) string { + return fmt.Sprintf(` +resource "vercel_project" "example" { + name = "test-acc-example-project-%[1]s" + + git_repository = { + type = "github" + repo = "%[2]s" + } +} + +resource "vercel_project_crons" "example" { + project_id = vercel_project.example.id + enabled = false +} +`, projectName, githubRepo) +} + +func testAccProjectCronsConfigUpdated(projectName string, githubRepo string) string { + return fmt.Sprintf(` +resource "vercel_project" "example" { + name = "test-acc-example-project-%[1]s" + + git_repository = { + type = "github" + repo = "%[2]s" + } +} + +resource "vercel_project_crons" "example" { + project_id = vercel_project.example.id + enabled = true +} +`, projectName, githubRepo) +} From b4b46627335fff807499c383a430c80c34115567 Mon Sep 17 00:00:00 2001 From: Douglas Harcourt Parsons Date: Tue, 27 May 2025 16:46:36 +0100 Subject: [PATCH 2/2] Go fmt --- vercel/provider.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vercel/provider.go b/vercel/provider.go index 075f1c24..a7cc8637 100644 --- a/vercel/provider.go +++ b/vercel/provider.go @@ -69,8 +69,8 @@ func (p *vercelProvider) Resources(_ context.Context) []func() resource.Resource newLogDrainResource, newMicrofrontendGroupMembershipResource, newMicrofrontendGroupResource, - newProjectDeploymentRetentionResource, - newProjectCronsResource, + newProjectDeploymentRetentionResource, + newProjectCronsResource, newProjectDomainResource, newProjectEnvironmentVariableResource, newProjectEnvironmentVariablesResource,