From 2ca4898921e8ee7d6568822963d2ba06793b1ea2 Mon Sep 17 00:00:00 2001 From: Douglas Harcourt Parsons Date: Mon, 22 Apr 2024 17:24:30 +0100 Subject: [PATCH] Add support for Deploy Hooks Deploy Hooks are available underneath the `git_repostiory` section of a `vercel_project`. These generate a URL that can be used to trigger new deployment of a specific `ref`. Existing on the `vercel_project` resource under `git_repository` is the only place that seems to make sense, as linking/unlinking a git repository causes all deploy hooks to require recreation, so they cannot exist as a separate resource. Closes #104 --- client/deploy_hooks.go | 84 ++++++ client/deployment.go | 2 +- client/environment_variable.go | 2 +- client/project.go | 72 ++--- docs/resources/project.md | 15 + vercel/data_source_project.go | 17 +- vercel/resource_deployment.go | 2 +- vercel/resource_project.go | 264 ++++++++++++++++-- vercel/resource_project_domain.go | 2 +- .../resource_project_environment_variable.go | 2 +- ...ource_project_environment_variable_test.go | 4 +- vercel/resource_project_test.go | 20 +- 12 files changed, 402 insertions(+), 84 deletions(-) create mode 100644 client/deploy_hooks.go diff --git a/client/deploy_hooks.go b/client/deploy_hooks.go new file mode 100644 index 00000000..cc50a56f --- /dev/null +++ b/client/deploy_hooks.go @@ -0,0 +1,84 @@ +package client + +import ( + "context" + "fmt" + "slices" + + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +type DeployHook struct { + Name string `json:"name"` + Ref string `json:"ref"` + URL string `json:"url"` + ID string `json:"id"` +} + +type CreateDeployHookRequest struct { + ProjectID string `json:"-"` + TeamID string `json:"-"` + Name string `json:"name"` + Ref string `json:"ref"` +} + +func (c *Client) CreateDeployHook(ctx context.Context, request CreateDeployHookRequest) (h DeployHook, err error) { + url := fmt.Sprintf("%s/v2/projects/%s/deploy-hooks", c.baseURL, request.ProjectID) + if c.teamID(request.TeamID) != "" { + url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(request.TeamID)) + } + payload := string(mustMarshal(request)) + tflog.Info(ctx, "creating deploy hook", map[string]interface{}{ + "url": url, + "payload": payload, + }) + + var r ProjectResponse + err = c.doRequest(clientRequest{ + ctx: ctx, + method: "POST", + url: url, + body: payload, + }, &r) + if err != nil { + return h, fmt.Errorf("error creating deploy hook: %w", err) + } + + // Reverse the list as newest created are at the end + slices.Reverse(r.Link.DeployHooks) + for _, hook := range r.Link.DeployHooks { + if hook.Name == request.Name && hook.Ref == request.Ref { + return hook, nil + } + } + + return h, fmt.Errorf("deploy hook was created successfully, but could not be found") +} + +type DeleteDeployHookRequest struct { + ProjectID string + TeamID string + ID string +} + +func (c *Client) DeleteDeployHook(ctx context.Context, request DeleteDeployHookRequest) error { + url := fmt.Sprintf("%s/v2/projects/%s/deploy-hooks/%s", c.baseURL, request.ProjectID, request.ID) + if c.teamID(request.TeamID) != "" { + url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(request.TeamID)) + } + payload := string(mustMarshal(request)) + tflog.Info(ctx, "creating deploy hook", map[string]interface{}{ + "url": url, + "payload": payload, + }) + + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "DELETE", + url: url, + }, nil) + if err != nil { + return fmt.Errorf("error deleting deploy hook: %w", err) + } + return nil +} diff --git a/client/deployment.go b/client/deployment.go index bcecd34c..7d82cc3f 100644 --- a/client/deployment.go +++ b/client/deployment.go @@ -150,7 +150,7 @@ func (e MissingFilesError) Error() string { } func (c *Client) getGitSource(ctx context.Context, projectID, ref, teamID string) (gs gitSource, err error) { - project, err := c.GetProject(ctx, projectID, teamID, false) + project, err := c.GetProject(ctx, projectID, teamID) if err != nil { return gs, fmt.Errorf("error getting project: %w", err) } diff --git a/client/environment_variable.go b/client/environment_variable.go index 87cc476a..c71f8029 100644 --- a/client/environment_variable.go +++ b/client/environment_variable.go @@ -123,7 +123,7 @@ func (c *Client) DeleteEnvironmentVariable(ctx context.Context, projectID, teamI }, nil) } -func (c *Client) getEnvironmentVariables(ctx context.Context, projectID, teamID string) ([]EnvironmentVariable, error) { +func (c *Client) GetEnvironmentVariables(ctx context.Context, projectID, teamID string) ([]EnvironmentVariable, error) { url := fmt.Sprintf("%s/v8/projects/%s/env?decrypt=true", c.baseURL, projectID) if c.teamID(teamID) != "" { url = fmt.Sprintf("%s&teamId=%s", url, c.teamID(teamID)) diff --git a/client/project.go b/client/project.go index b0b70de9..282d6281 100644 --- a/client/project.go +++ b/client/project.go @@ -64,11 +64,6 @@ func (c *Client) CreateProject(ctx context.Context, teamID string, request Creat if err != nil { return r, err } - env, err := c.getEnvironmentVariables(ctx, r.ID, teamID) - if err != nil { - return r, fmt.Errorf("error getting environment variables: %w", err) - } - r.EnvironmentVariables = env r.TeamID = c.teamID(teamID) return r, err } @@ -96,6 +91,7 @@ type Repository struct { Type string Repo string ProductionBranch *string + DeployHooks []DeployHook } // getRepoNameFromURL is a helper method to extract the repo name from a GitLab URL. @@ -120,18 +116,21 @@ func (r *ProjectResponse) Repository() *Repository { Type: "github", Repo: fmt.Sprintf("%s/%s", r.Link.Org, r.Link.Repo), ProductionBranch: r.Link.ProductionBranch, + DeployHooks: r.Link.DeployHooks, } case "gitlab": return &Repository{ Type: "gitlab", Repo: fmt.Sprintf("%s/%s", r.Link.ProjectNamespace, getRepoNameFromURL(r.Link.ProjectURL)), ProductionBranch: r.Link.ProductionBranch, + DeployHooks: r.Link.DeployHooks, } case "bitbucket": return &Repository{ Type: "bitbucket", Repo: fmt.Sprintf("%s/%s", r.Link.Owner, r.Link.Slug), ProductionBranch: r.Link.ProductionBranch, + DeployHooks: r.Link.DeployHooks, } } return nil @@ -139,14 +138,13 @@ func (r *ProjectResponse) Repository() *Repository { // ProjectResponse defines the information Vercel returns about a project. type ProjectResponse struct { - BuildCommand *string `json:"buildCommand"` - CommandForIgnoringBuildStep *string `json:"commandForIgnoringBuildStep"` - DevCommand *string `json:"devCommand"` - EnvironmentVariables []EnvironmentVariable `json:"env"` - Framework *string `json:"framework"` - ID string `json:"id"` - TeamID string `json:"-"` - InstallCommand *string `json:"installCommand"` + BuildCommand *string `json:"buildCommand"` + CommandForIgnoringBuildStep *string `json:"commandForIgnoringBuildStep"` + DevCommand *string `json:"devCommand"` + Framework *string `json:"framework"` + ID string `json:"id"` + TeamID string `json:"-"` + InstallCommand *string `json:"installCommand"` Link *struct { Type string `json:"type"` // github @@ -160,7 +158,8 @@ type ProjectResponse struct { ProjectURL string `json:"projectUrl"` ProjectID int64 `json:"projectId,string"` // production branch - ProductionBranch *string `json:"productionBranch"` + ProductionBranch *string `json:"productionBranch"` + DeployHooks []DeployHook `json:"deployHooks"` } `json:"link"` Name string `json:"name"` OutputDirectory *string `json:"outputDirectory"` @@ -175,14 +174,13 @@ type ProjectResponse struct { } // GetProject retrieves information about an existing project from Vercel. -func (c *Client) GetProject(ctx context.Context, projectID, teamID string, shouldFetchEnvironmentVariables bool) (r ProjectResponse, err error) { +func (c *Client) GetProject(ctx context.Context, projectID, teamID string) (r ProjectResponse, err error) { url := fmt.Sprintf("%s/v10/projects/%s", c.baseURL, projectID) if c.teamID(teamID) != "" { url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(teamID)) } tflog.Info(ctx, "getting project", map[string]interface{}{ - "url": url, - "shouldFetchEnvironment": shouldFetchEnvironmentVariables, + "url": url, }) err = c.doRequest(clientRequest{ ctx: ctx, @@ -194,16 +192,6 @@ func (c *Client) GetProject(ctx context.Context, projectID, teamID string, shoul return r, fmt.Errorf("unable to get project: %w", err) } - if shouldFetchEnvironmentVariables { - r.EnvironmentVariables, err = c.getEnvironmentVariables(ctx, projectID, teamID) - if err != nil { - return r, fmt.Errorf("error getting environment variables for project: %w", err) - } - } else { - // The get project endpoint returns environment variables, but returns them fully - // encrypted. This isn't useful, so we just remove them. - r.EnvironmentVariables = nil - } r.TeamID = c.teamID(teamID) return r, err } @@ -257,16 +245,15 @@ type UpdateProjectRequest struct { } // UpdateProject updates an existing projects configuration within Vercel. -func (c *Client) UpdateProject(ctx context.Context, projectID, teamID string, request UpdateProjectRequest, shouldFetchEnvironmentVariables bool) (r ProjectResponse, err error) { +func (c *Client) UpdateProject(ctx context.Context, projectID, teamID string, request UpdateProjectRequest) (r ProjectResponse, err error) { url := fmt.Sprintf("%s/v9/projects/%s", c.baseURL, projectID) if c.teamID(teamID) != "" { url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(teamID)) } payload := string(mustMarshal(request)) tflog.Info(ctx, "updating project", map[string]interface{}{ - "url": url, - "payload": payload, - "shouldFetchEnvironmentVariables": shouldFetchEnvironmentVariables, + "url": url, + "payload": payload, }) err = c.doRequest(clientRequest{ ctx: ctx, @@ -277,14 +264,6 @@ func (c *Client) UpdateProject(ctx context.Context, projectID, teamID string, re if err != nil { return r, err } - if shouldFetchEnvironmentVariables { - r.EnvironmentVariables, err = c.getEnvironmentVariables(ctx, r.ID, teamID) - if err != nil { - return r, fmt.Errorf("error getting environment variables for project: %w", err) - } - } else { - r.EnvironmentVariables = nil - } r.TeamID = c.teamID(teamID) return r, err @@ -315,11 +294,6 @@ func (c *Client) UpdateProductionBranch(ctx context.Context, request UpdateProdu if err != nil { return r, err } - env, err := c.getEnvironmentVariables(ctx, r.ID, request.TeamID) - if err != nil { - return r, fmt.Errorf("error getting environment variables: %w", err) - } - r.EnvironmentVariables = env r.TeamID = c.teamID(c.teamID(request.TeamID)) return r, err } @@ -340,11 +314,6 @@ func (c *Client) UnlinkGitRepoFromProject(ctx context.Context, projectID, teamID if err != nil { return r, fmt.Errorf("error unlinking git repo: %w", err) } - env, err := c.getEnvironmentVariables(ctx, r.ID, teamID) - if err != nil { - return r, fmt.Errorf("error getting environment variables: %w", err) - } - r.EnvironmentVariables = env r.TeamID = c.teamID(teamID) return r, err } @@ -374,11 +343,6 @@ func (c *Client) LinkGitRepoToProject(ctx context.Context, request LinkGitRepoTo if err != nil { return r, fmt.Errorf("error linking git repo: %w", err) } - env, err := c.getEnvironmentVariables(ctx, r.ID, request.TeamID) - if err != nil { - return r, fmt.Errorf("error getting environment variables: %w", err) - } - r.EnvironmentVariables = env r.TeamID = c.teamID(c.teamID(request.TeamID)) return r, err } diff --git a/docs/resources/project.md b/docs/resources/project.md index b1002c69..05839611 100644 --- a/docs/resources/project.md +++ b/docs/resources/project.md @@ -107,8 +107,23 @@ Required: Optional: +- `deploy_hooks` (Attributes Set) Deploy hooks are unique URLs that allow you to trigger a deployment of a given branch. See https://vercel.com/docs/deployments/deploy-hooks for full information. (see [below for nested schema](#nestedatt--git_repository--deploy_hooks)) - `production_branch` (String) By default, every commit pushed to the main branch will trigger a Production Deployment instead of the usual Preview Deployment. You can switch to a different branch here. + +### Nested Schema for `git_repository.deploy_hooks` + +Required: + +- `name` (String) The name of the deploy hook. +- `ref` (String) The branch or commit hash that should be deployed. + +Read-Only: + +- `id` (String) The ID of the deploy hook. +- `url` (String, Sensitive) A URL that, when a POST request is made to, will trigger a new deployment. + + ### Nested Schema for `password_protection` diff --git a/vercel/data_source_project.go b/vercel/data_source_project.go index 9bee75cc..0324609a 100644 --- a/vercel/data_source_project.go +++ b/vercel/data_source_project.go @@ -243,8 +243,9 @@ type ProjectDataSource struct { AutoExposeSystemEnvVars types.Bool `tfsdk:"automatically_expose_system_environment_variables"` } -func convertResponseToProjectDataSource(ctx context.Context, response client.ProjectResponse, plan Project) (ProjectDataSource, error) { - project, err := convertResponseToProject(ctx, response, plan) +func convertResponseToProjectDataSource(ctx context.Context, response client.ProjectResponse, plan Project, environmentVariables []client.EnvironmentVariable) (ProjectDataSource, error) { + plan.Environment = types.SetValueMust(envVariableElemType, []attr.Value{}) + project, err := convertResponseToProject(ctx, response, plan, environmentVariables) if err != nil { return ProjectDataSource{}, err } @@ -288,7 +289,7 @@ func (d *projectDataSource) Read(ctx context.Context, req datasource.ReadRequest return } - out, err := d.client.GetProject(ctx, config.Name.ValueString(), config.TeamID.ValueString(), true) + out, err := d.client.GetProject(ctx, config.Name.ValueString(), config.TeamID.ValueString()) if err != nil { resp.Diagnostics.AddError( "Error reading project", @@ -301,7 +302,15 @@ func (d *projectDataSource) Read(ctx context.Context, req datasource.ReadRequest return } - result, err := convertResponseToProjectDataSource(ctx, out, nullProject) + environmentVariables, err := d.client.GetEnvironmentVariables(ctx, out.ID, out.TeamID) + if err != nil { + resp.Diagnostics.AddError( + "Error reading project environment variables", + "Could not read project, unexpected error: "+err.Error(), + ) + return + } + result, err := convertResponseToProjectDataSource(ctx, out, nullProject, environmentVariables) if err != nil { resp.Diagnostics.AddError( "Error converting project response to model", diff --git a/vercel/resource_deployment.go b/vercel/resource_deployment.go index 8d7a53de..3483dfad 100644 --- a/vercel/resource_deployment.go +++ b/vercel/resource_deployment.go @@ -524,7 +524,7 @@ func (r *deploymentResource) Create(ctx context.Context, req resource.CreateRequ Ref: plan.Ref.ValueString(), } - _, err = r.client.GetProject(ctx, plan.ProjectID.ValueString(), plan.TeamID.ValueString(), false) + _, err = r.client.GetProject(ctx, plan.ProjectID.ValueString(), plan.TeamID.ValueString()) if client.NotFound(err) { resp.Diagnostics.AddError( "Error creating deployment", diff --git a/vercel/resource_project.go b/vercel/resource_project.go index 2600bc70..33197d16 100644 --- a/vercel/resource_project.go +++ b/vercel/resource_project.go @@ -6,6 +6,7 @@ import ( "regexp" "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" @@ -22,8 +23,9 @@ import ( ) var ( - _ resource.Resource = &projectResource{} - _ resource.ResourceWithConfigure = &projectResource{} + _ resource.Resource = &projectResource{} + _ resource.ResourceWithConfigure = &projectResource{} + _ resource.ResourceWithImportState = &projectResource{} ) func newProjectResource() resource.Resource { @@ -175,6 +177,31 @@ At this time you cannot use a Vercel Project resource with in-line ` + "`environ Optional: true, Computed: true, }, + "deploy_hooks": schema.SetNestedAttribute{ + Description: "Deploy hooks are unique URLs that allow you to trigger a deployment of a given branch. See https://vercel.com/docs/deployments/deploy-hooks for full information.", + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The ID of the deploy hook.", + Computed: true, + }, + "name": schema.StringAttribute{ + Description: "The name of the deploy hook.", + Required: true, + }, + "ref": schema.StringAttribute{ + Description: "The branch or commit hash that should be deployed.", + Required: true, + }, + "url": schema.StringAttribute{ + Description: "A URL that, when a POST request is made to, will trigger a new deployment.", + Computed: true, + Sensitive: true, + }, + }, + }, + }, }, }, "vercel_authentication": schema.SingleNestedAttribute{ @@ -455,11 +482,19 @@ func (e *EnvironmentItem) toEnvironmentVariableRequest() client.EnvironmentVaria } } +type DeployHook struct { + Name types.String `tfsdk:"name"` + Ref types.String `tfsdk:"ref"` + URL types.String `tfsdk:"url"` + ID types.String `tfsdk:"id"` +} + // GitRepository reflects the state terraform stores internally for a nested git_repository block on a project resource. type GitRepository struct { Type types.String `tfsdk:"type"` Repo types.String `tfsdk:"repo"` ProductionBranch types.String `tfsdk:"production_branch"` + DeployHooks types.Set `tfsdk:"deploy_hooks"` } func (g *GitRepository) isDifferentRepo(other *GitRepository) bool { @@ -649,7 +684,23 @@ func hasSameTarget(p EnvironmentItem, target []string) bool { return true } -func convertResponseToProject(ctx context.Context, response client.ProjectResponse, plan Project) (Project, error) { +var deployHookType = types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "name": types.StringType, + "ref": types.StringType, + "url": types.StringType, + "id": types.StringType, + }, +} + +type deployHook struct { + Name string `tfsdk:"name"` + Ref string `tfsdk:"ref"` + URL string `tfsdk:"url"` + ID string `tfsdk:"id"` +} + +func convertResponseToProject(ctx context.Context, response client.ProjectResponse, plan Project, environmentVariables []client.EnvironmentVariable) (Project, error) { fields := plan.coercedFields() var gr *GitRepository @@ -658,10 +709,27 @@ func convertResponseToProject(ctx context.Context, response client.ProjectRespon Type: types.StringValue(repo.Type), Repo: types.StringValue(repo.Repo), ProductionBranch: types.StringNull(), + DeployHooks: types.SetNull(deployHookType), } if repo.ProductionBranch != nil { gr.ProductionBranch = types.StringValue(*repo.ProductionBranch) } + if repo.DeployHooks != nil && plan.GitRepository != nil && !plan.GitRepository.DeployHooks.IsNull() { + var dh []deployHook + for _, h := range repo.DeployHooks { + dh = append(dh, deployHook{ + Name: h.Name, + Ref: h.Ref, + URL: h.URL, + ID: h.ID, + }) + } + hooks, diags := types.SetValueFrom(ctx, deployHookType, dh) + if diags.HasError() { + return Project{}, fmt.Errorf("error reading project deploy hooks: %s - %s", diags[0].Summary(), diags[0].Detail()) + } + gr.DeployHooks = hooks + } } var pp *PasswordProtectionWithPassword @@ -702,7 +770,7 @@ func convertResponseToProject(ctx context.Context, response client.ProjectRespon } var env []attr.Value - for _, e := range response.EnvironmentVariables { + for _, e := range environmentVariables { target := []attr.Value{} for _, t := range e.Target { target = append(target, types.StringValue(t)) @@ -758,7 +826,7 @@ func convertResponseToProject(ctx context.Context, response client.ProjectRespon } environmentEntry := types.SetValueMust(envVariableElemType, env) - if len(response.EnvironmentVariables) == 0 && plan.Environment.IsNull() { + if plan.Environment.IsNull() { environmentEntry = types.SetNull(envVariableElemType) } @@ -814,7 +882,16 @@ func (r *projectResource) Create(ctx context.Context, req resource.CreateRequest return } - result, err := convertResponseToProject(ctx, out, plan) + environmentVariables, err := r.client.GetEnvironmentVariables(ctx, out.ID, out.TeamID) + if err != nil { + resp.Diagnostics.AddError( + "Error reading project environment variables", + "Could not create project, unexpected error: "+err.Error(), + ) + return + } + + result, err := convertResponseToProject(ctx, out, plan, environmentVariables) if err != nil { resp.Diagnostics.AddError( "Error converting project response to model", @@ -832,8 +909,46 @@ func (r *projectResource) Create(ctx context.Context, req resource.CreateRequest return } + if plan.GitRepository != nil && !plan.GitRepository.DeployHooks.IsNull() && !plan.GitRepository.DeployHooks.IsUnknown() { + var hooks []DeployHook + diags := plan.GitRepository.DeployHooks.ElementsAs(ctx, &hooks, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + for _, hook := range hooks { + hook, err := r.client.CreateDeployHook(ctx, client.CreateDeployHookRequest{ + ProjectID: result.ID.ValueString(), + TeamID: result.TeamID.ValueString(), + Name: hook.Name.ValueString(), + Ref: hook.Ref.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError( + "Error creating deploy hook", + "Could not create project, unexpected error: "+err.Error(), + ) + return + } + out.Link.DeployHooks = append(out.Link.DeployHooks, hook) + } + result, err := convertResponseToProject(ctx, out, plan, environmentVariables) + if err != nil { + resp.Diagnostics.AddError( + "Error converting project response to model", + "Could not create project, unexpected error: "+err.Error(), + ) + return + } + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + if plan.PasswordProtection != nil || plan.VercelAuthentication != nil || plan.TrustedIps != nil || !plan.AutoExposeSystemEnvVars.IsNull() { - out, err = r.client.UpdateProject(ctx, result.ID.ValueString(), plan.TeamID.ValueString(), plan.toUpdateProjectRequest(plan.Name.ValueString()), !plan.Environment.IsNull()) + out, err = r.client.UpdateProject(ctx, result.ID.ValueString(), plan.TeamID.ValueString(), plan.toUpdateProjectRequest(plan.Name.ValueString())) if err != nil { resp.Diagnostics.AddError( "Error updating project as part of creating project", @@ -842,7 +957,7 @@ func (r *projectResource) Create(ctx context.Context, req resource.CreateRequest return } - result, err = convertResponseToProject(ctx, out, plan) + result, err = convertResponseToProject(ctx, out, plan, environmentVariables) if err != nil { resp.Diagnostics.AddError( "Error converting project response to model", @@ -900,7 +1015,7 @@ func (r *projectResource) Create(ctx context.Context, req resource.CreateRequest return } - result, err = convertResponseToProject(ctx, out, plan) + result, err = convertResponseToProject(ctx, out, plan, environmentVariables) if err != nil { resp.Diagnostics.AddError( "Error converting project response to model", @@ -930,7 +1045,7 @@ func (r *projectResource) Read(ctx context.Context, req resource.ReadRequest, re return } - out, err := r.client.GetProject(ctx, state.ID.ValueString(), state.TeamID.ValueString(), !state.Environment.IsNull()) + out, err := r.client.GetProject(ctx, state.ID.ValueString(), state.TeamID.ValueString()) if client.NotFound(err) { resp.State.RemoveResource(ctx) return @@ -947,7 +1062,15 @@ func (r *projectResource) Read(ctx context.Context, req resource.ReadRequest, re return } - result, err := convertResponseToProject(ctx, out, state) + environmentVariables, err := r.client.GetEnvironmentVariables(ctx, out.ID, out.TeamID) + if err != nil { + resp.Diagnostics.AddError( + "Error reading project environment variables", + "Could not read project, unexpected error: "+err.Error(), + ) + return + } + result, err := convertResponseToProject(ctx, out, state, environmentVariables) if err != nil { resp.Diagnostics.AddError( "Error converting project response to model", @@ -998,6 +1121,51 @@ func diffEnvVars(oldVars, newVars []EnvironmentItem) (toCreate, toRemove []Envir return toCreate, toRemove } +func containsDeployHook(hooks []DeployHook, h DeployHook) bool { + for _, hook := range hooks { + if hook.ID == h.ID { + return true + } + } + return false +} + +func diffDeployHooks(ctx context.Context, new, old *GitRepository) (toCreate, toRemove []DeployHook, diags diag.Diagnostics) { + if new == nil && old == nil { + return nil, nil, nil + } + if new == nil { + diags = old.DeployHooks.ElementsAs(ctx, &toRemove, false) + return nil, toRemove, diags + } + if old == nil { + diags = new.DeployHooks.ElementsAs(ctx, &toCreate, false) + return toCreate, nil, diags + } + var oldHooks []DeployHook + var newHooks []DeployHook + diags = old.DeployHooks.ElementsAs(ctx, &oldHooks, false) + if diags.HasError() { + return nil, nil, diags + } + diags = new.DeployHooks.ElementsAs(ctx, &newHooks, false) + if diags.HasError() { + return nil, nil, diags + } + + for _, h := range oldHooks { + if !containsDeployHook(newHooks, h) { + toRemove = append(toRemove, h) + } + } + for _, h := range newHooks { + if !containsDeployHook(oldHooks, h) { + toCreate = append(toCreate, h) + } + } + return toCreate, toRemove, diags +} + // Update will update a project and it's associated environment variables via the vercel API. // Environment variables are manually diffed and updated individually. Once the environment // variables are all updated, the project is updated too. @@ -1112,7 +1280,7 @@ func (r *projectResource) Update(ctx context.Context, req resource.UpdateRequest } } - out, err := r.client.UpdateProject(ctx, state.ID.ValueString(), state.TeamID.ValueString(), plan.toUpdateProjectRequest(state.Name.ValueString()), !plan.Environment.IsNull()) + out, err := r.client.UpdateProject(ctx, state.ID.ValueString(), state.TeamID.ValueString(), plan.toUpdateProjectRequest(state.Name.ValueString())) if err != nil { resp.Diagnostics.AddError( "Error updating project", @@ -1210,11 +1378,65 @@ func (r *projectResource) Update(ctx context.Context, req resource.UpdateRequest } } - result, err := convertResponseToProject(ctx, out, plan) + hooksToCreate, hooksToRemove, diags := diffDeployHooks(ctx, plan.GitRepository, state.GitRepository) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + for _, h := range hooksToRemove { + err := r.client.DeleteDeployHook(ctx, client.DeleteDeployHookRequest{ + ProjectID: plan.ID.ValueString(), + TeamID: plan.TeamID.ValueString(), + ID: h.ID.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError( + "Error deleting deploy hook", + "Could not update project, unexpected error: "+err.Error(), + ) + return + } + } + for _, h := range hooksToCreate { + _, err := r.client.CreateDeployHook(ctx, client.CreateDeployHookRequest{ + ProjectID: plan.ID.ValueString(), + TeamID: plan.TeamID.ValueString(), + Name: h.Name.ValueString(), + Ref: h.Ref.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError( + "Error creating deploy hook", + "Could not update project, unexpected error: "+err.Error(), + ) + return + } + } + if hooksToCreate != nil || hooksToRemove != nil { + // Re-fetch the project to ensure the hooks afterwards are all correct + out, err = r.client.GetProject(ctx, plan.ID.ValueString(), plan.TeamID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Error reading project", + "Could not update project, unexpected error: "+err.Error(), + ) + return + } + } + + environmentVariables, err := r.client.GetEnvironmentVariables(ctx, out.ID, out.TeamID) + if err != nil { + resp.Diagnostics.AddError( + "Error reading project environment variables", + "Could not update project, unexpected error: "+err.Error(), + ) + return + } + result, err := convertResponseToProject(ctx, out, plan, environmentVariables) if err != nil { resp.Diagnostics.AddError( "Error converting project response to model", - "Could not create project, unexpected error: "+err.Error(), + "Could not update project, unexpected error: "+err.Error(), ) return } @@ -1274,7 +1496,7 @@ func (r *projectResource) ImportState(ctx context.Context, req resource.ImportSt ) } - out, err := r.client.GetProject(ctx, projectID, teamID, false) + out, err := r.client.GetProject(ctx, projectID, teamID) if err != nil { resp.Diagnostics.AddError( "Error reading project", @@ -1287,11 +1509,19 @@ func (r *projectResource) ImportState(ctx context.Context, req resource.ImportSt return } - result, err := convertResponseToProject(ctx, out, nullProject) + environmentVariables, err := r.client.GetEnvironmentVariables(ctx, out.ID, out.TeamID) + if err != nil { + resp.Diagnostics.AddError( + "Error reading project environment variables", + "Could not import project, unexpected error: "+err.Error(), + ) + return + } + result, err := convertResponseToProject(ctx, out, nullProject, environmentVariables) if err != nil { resp.Diagnostics.AddError( "Error converting project response to model", - "Could not create project, unexpected error: "+err.Error(), + "Could not import project, unexpected error: "+err.Error(), ) return } diff --git a/vercel/resource_project_domain.go b/vercel/resource_project_domain.go index 488dba83..b4dbf5ba 100644 --- a/vercel/resource_project_domain.go +++ b/vercel/resource_project_domain.go @@ -148,7 +148,7 @@ func (r *projectDomainResource) Create(ctx context.Context, req resource.CreateR return } - _, err := r.client.GetProject(ctx, plan.ProjectID.ValueString(), plan.TeamID.ValueString(), false) + _, err := r.client.GetProject(ctx, plan.ProjectID.ValueString(), plan.TeamID.ValueString()) if client.NotFound(err) { resp.Diagnostics.AddError( "Error creating project domain", diff --git a/vercel/resource_project_environment_variable.go b/vercel/resource_project_environment_variable.go index 4a63295a..cbf1e142 100644 --- a/vercel/resource_project_environment_variable.go +++ b/vercel/resource_project_environment_variable.go @@ -213,7 +213,7 @@ func (r *projectEnvironmentVariableResource) Create(ctx context.Context, req res return } - _, err := r.client.GetProject(ctx, plan.ProjectID.ValueString(), plan.TeamID.ValueString(), false) + _, err := r.client.GetProject(ctx, plan.ProjectID.ValueString(), plan.TeamID.ValueString()) if client.NotFound(err) { resp.Diagnostics.AddError( "Error creating project environment variable", diff --git a/vercel/resource_project_environment_variable_test.go b/vercel/resource_project_environment_variable_test.go index 4294c988..d52d8eab 100644 --- a/vercel/resource_project_environment_variable_test.go +++ b/vercel/resource_project_environment_variable_test.go @@ -37,12 +37,12 @@ func testAccProjectEnvironmentVariablesDoNotExist(n, teamID string) resource.Tes return fmt.Errorf("no ID is set") } - project, err := testClient().GetProject(context.TODO(), rs.Primary.ID, teamID, true) + envs, err := testClient().GetEnvironmentVariables(context.TODO(), rs.Primary.ID, teamID) if err != nil { return fmt.Errorf("could not fetch the project: %w", err) } - if len(project.EnvironmentVariables) != 0 { + if len(envs) != 0 { return fmt.Errorf("project environment variables not deleted, they still exist") } diff --git a/vercel/resource_project_test.go b/vercel/resource_project_test.go index 39740726..9d23c705 100644 --- a/vercel/resource_project_test.go +++ b/vercel/resource_project_test.go @@ -283,7 +283,7 @@ func testAccProjectExists(n, teamID string) resource.TestCheckFunc { return fmt.Errorf("no projectID is set") } - _, err := testClient().GetProject(context.TODO(), rs.Primary.ID, teamID, false) + _, err := testClient().GetProject(context.TODO(), rs.Primary.ID, teamID) return err } } @@ -299,7 +299,7 @@ func testAccProjectDestroy(n, teamID string) resource.TestCheckFunc { return fmt.Errorf("no projectID is set") } - _, err := testClient().GetProject(context.TODO(), rs.Primary.ID, teamID, false) + _, err := testClient().GetProject(context.TODO(), rs.Primary.ID, teamID) if err == nil { return fmt.Errorf("expected not_found error, but got no error") } @@ -487,6 +487,12 @@ resource "vercel_project" "test_git" { git_repository = { type = "github" repo = "%s" + deploy_hooks = [ + { + ref = "main" + name = "some deploy hook" + } + ] } environment = [ { @@ -510,6 +516,16 @@ resource "vercel_project" "test_git" { type = "github" repo = "%s" production_branch = "staging" + deploy_hooks = [ + { + ref = "main" + name = "some deploy hook" + }, + { + ref = "main" + name = "some other hook" + } + ] } environment = [ {