internal/provider/resource_gitlab_project_hook.go (479 lines of code) (raw):
package provider
import (
"context"
"regexp"
"strconv"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
gitlab "gitlab.com/gitlab-org/api/client-go"
"gitlab.com/gitlab-org/terraform-provider-gitlab/internal/provider/api"
"gitlab.com/gitlab-org/terraform-provider-gitlab/internal/provider/utils"
)
var (
_ resource.Resource = &gitlabProjectHookResource{}
_ resource.ResourceWithConfigure = &gitlabProjectHookResource{}
_ resource.ResourceWithImportState = &gitlabProjectHookResource{}
_ resource.ResourceWithUpgradeState = &gitlabProjectHookResource{}
)
func init() {
registerResource(NewGitLabProjectHookResource)
}
// NewGitLabProjectHookResource is a helper function to simplify the provider implementation.
func NewGitLabProjectHookResource() resource.Resource {
return &gitlabProjectHookResource{}
}
type gitlabProjectHookResourceModel struct {
ID types.String `tfsdk:"id"`
Project types.String `tfsdk:"project"`
ProjectID types.Int64 `tfsdk:"project_id"`
HookID types.Int64 `tfsdk:"hook_id"`
URL types.String `tfsdk:"url"`
Token types.String `tfsdk:"token"`
Name types.String `tfsdk:"name"`
Description types.String `tfsdk:"description"`
PushEvents types.Bool `tfsdk:"push_events"`
PushEventsBranchFilter types.String `tfsdk:"push_events_branch_filter"`
IssuesEvents types.Bool `tfsdk:"issues_events"`
ConfidentialIssuesEvents types.Bool `tfsdk:"confidential_issues_events"`
MergeRequestsEvents types.Bool `tfsdk:"merge_requests_events"`
TagPushEvents types.Bool `tfsdk:"tag_push_events"`
NoteEvents types.Bool `tfsdk:"note_events"`
ConfidentialNoteEvents types.Bool `tfsdk:"confidential_note_events"`
JobEvents types.Bool `tfsdk:"job_events"`
PipelineEvents types.Bool `tfsdk:"pipeline_events"`
WikiPageEvents types.Bool `tfsdk:"wiki_page_events"`
ResourceAccessTokenEvents types.Bool `tfsdk:"resource_access_token_events"`
DeploymentEvents types.Bool `tfsdk:"deployment_events"`
ReleasesEvents types.Bool `tfsdk:"releases_events"`
EnableSSLVerification types.Bool `tfsdk:"enable_ssl_verification"`
CustomWebhookTemplate types.String `tfsdk:"custom_webhook_template"`
// Model defined in resource_gitlab_group_hook.go
CustomHeaders []*gitlabHookCustomHeaderModel `tfsdk:"custom_headers"`
}
type gitlabProjectHookResource struct {
client *gitlab.Client
}
func (r *gitlabProjectHookResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_project_hook"
}
func (r *gitlabProjectHookResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = r.getSchema()
}
func (r *gitlabProjectHookResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
// Prevent panic if the provider has not been configured.
if req.ProviderData == nil {
return
}
resourceData := req.ProviderData.(*GitLabResourceData)
r.client = resourceData.Client
}
// ImportState imports the resource into the Terraform state.
func (r *gitlabProjectHookResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}
func (r *gitlabProjectHookResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data *gitlabProjectHookResourceModel
diags := req.Config.Get(ctx, &data)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
options := &gitlab.AddProjectHookOptions{
Name: data.Name.ValueStringPointer(),
Description: data.Description.ValueStringPointer(),
URL: data.URL.ValueStringPointer(),
PushEvents: data.PushEvents.ValueBoolPointer(),
PushEventsBranchFilter: data.PushEventsBranchFilter.ValueStringPointer(),
IssuesEvents: data.IssuesEvents.ValueBoolPointer(),
ConfidentialIssuesEvents: data.ConfidentialIssuesEvents.ValueBoolPointer(),
MergeRequestsEvents: data.MergeRequestsEvents.ValueBoolPointer(),
TagPushEvents: data.TagPushEvents.ValueBoolPointer(),
NoteEvents: data.NoteEvents.ValueBoolPointer(),
ConfidentialNoteEvents: data.ConfidentialNoteEvents.ValueBoolPointer(),
JobEvents: data.JobEvents.ValueBoolPointer(),
PipelineEvents: data.PipelineEvents.ValueBoolPointer(),
WikiPageEvents: data.WikiPageEvents.ValueBoolPointer(),
ResourceAccessTokenEvents: data.ResourceAccessTokenEvents.ValueBoolPointer(),
DeploymentEvents: data.DeploymentEvents.ValueBoolPointer(),
ReleasesEvents: data.ReleasesEvents.ValueBoolPointer(),
EnableSSLVerification: data.EnableSSLVerification.ValueBoolPointer(),
CustomWebhookTemplate: data.CustomWebhookTemplate.ValueStringPointer(),
}
if !data.Token.IsNull() {
options.Token = data.Token.ValueStringPointer()
}
if len(data.CustomHeaders) > 0 {
headers := make([]*gitlab.HookCustomHeader, 0, len(data.CustomHeaders))
for _, header := range data.CustomHeaders {
headers = append(headers, &gitlab.HookCustomHeader{
Key: header.Key.ValueString(),
Value: header.Value.ValueString(),
})
}
options.CustomHeaders = &headers
}
tflog.Debug(ctx, "creating gitlab project hook with details", map[string]any{
"project": data.Project,
"url": data.URL.ValueString(),
})
hook, _, err := r.client.Projects.AddProjectHook(data.Project.ValueString(), options, gitlab.WithContext(ctx))
if err != nil {
resp.Diagnostics.AddError("Error creating GitLab project hook", err.Error())
return
}
data.ID = types.StringValue(utils.BuildTwoPartID(data.Project.ValueStringPointer(), gitlab.Ptr(strconv.Itoa(hook.ID))))
data.modelToStateModel(hook)
resp.Diagnostics.Append(resp.State.Set(ctx, data)...)
}
func (r *gitlabProjectHookResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var data *gitlabProjectHookResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
project, hookId, err := data.ResourceGitlabProjectHookParseId(data.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError("Error reading GitLab project hook", err.Error())
return
}
tflog.Debug(ctx, "reading gitlab project hook with details", map[string]any{
"project": project,
"id": hookId,
})
hook, _, err := r.client.Projects.GetProjectHook(project, hookId, gitlab.WithContext(ctx))
if err != nil {
// Project/Hook not found
if api.Is404(err) {
tflog.Debug(ctx, "gitlab project hook not found, removing from state", map[string]any{
"project": project,
"id": hookId,
})
resp.State.RemoveResource(ctx)
} else {
// It's a real error
resp.Diagnostics.AddError("Error reading GitLab project hook", err.Error())
}
return
}
data.Project = types.StringValue(project)
data.modelToStateModel(hook)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *gitlabProjectHookResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var data *gitlabProjectHookResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
project, hookId, err := data.ResourceGitlabProjectHookParseId(data.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError("Error reading GitLab project hook", err.Error())
return
}
options := &gitlab.EditProjectHookOptions{
Name: data.Name.ValueStringPointer(),
Description: data.Description.ValueStringPointer(),
URL: data.URL.ValueStringPointer(),
PushEvents: data.PushEvents.ValueBoolPointer(),
PushEventsBranchFilter: data.PushEventsBranchFilter.ValueStringPointer(),
IssuesEvents: data.IssuesEvents.ValueBoolPointer(),
ConfidentialIssuesEvents: data.ConfidentialIssuesEvents.ValueBoolPointer(),
MergeRequestsEvents: data.MergeRequestsEvents.ValueBoolPointer(),
TagPushEvents: data.TagPushEvents.ValueBoolPointer(),
NoteEvents: data.NoteEvents.ValueBoolPointer(),
ConfidentialNoteEvents: data.ConfidentialNoteEvents.ValueBoolPointer(),
JobEvents: data.JobEvents.ValueBoolPointer(),
PipelineEvents: data.PipelineEvents.ValueBoolPointer(),
WikiPageEvents: data.WikiPageEvents.ValueBoolPointer(),
ResourceAccessTokenEvents: data.ResourceAccessTokenEvents.ValueBoolPointer(),
DeploymentEvents: data.DeploymentEvents.ValueBoolPointer(),
ReleasesEvents: data.ReleasesEvents.ValueBoolPointer(),
EnableSSLVerification: data.EnableSSLVerification.ValueBoolPointer(),
CustomWebhookTemplate: data.CustomWebhookTemplate.ValueStringPointer(),
}
if !data.Token.IsNull() {
options.Token = data.Token.ValueStringPointer()
}
if len(data.CustomHeaders) > 0 {
headers := make([]*gitlab.HookCustomHeader, 0, len(data.CustomHeaders))
for _, header := range data.CustomHeaders {
headers = append(headers, &gitlab.HookCustomHeader{
Key: header.Key.ValueString(),
Value: header.Value.ValueString(),
})
}
options.CustomHeaders = &headers
}
tflog.Debug(ctx, "updating gitlab project hook with details", map[string]any{
"project": data.Project,
"url": data.URL.ValueString(),
})
hook, _, err := r.client.Projects.EditProjectHook(project, hookId, options, gitlab.WithContext(ctx))
if err != nil {
resp.Diagnostics.AddError("Error creating GitLab project hook", err.Error())
return
}
data.modelToStateModel(hook)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *gitlabProjectHookResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var data *gitlabProjectHookResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
project, hookId, err := data.ResourceGitlabProjectHookParseId(data.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError("Error reading GitLab project hook", err.Error())
return
}
_, err = r.client.Projects.DeleteProjectHook(project, hookId, gitlab.WithContext(ctx))
if err != nil {
resp.Diagnostics.AddError("Error deleting GitLab project hook", err.Error())
return
}
resp.State.RemoveResource(ctx)
}
func (d *gitlabProjectHookResource) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader {
return map[int64]resource.StateUpgrader{
0: {
PriorSchema: gitlab.Ptr(d.getSchema()),
StateUpgrader: func(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) {
var data *gitlabProjectHookResourceModel
// Read Terraform plan data into the model
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
data.v0StateUpgrade(ctx)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
tflog.Debug(ctx, "migrated `id` attribute for V0 to V1", map[string]any{"v1-id": data.ID.ValueString()})
},
},
}
}
// Retrieve the attributes for the schema. Separated out from the rest of the schema
// so that the migration can refer to it more easily
func (d *gitlabProjectHookResource) getSchema() schema.Schema {
return schema.Schema{
Version: 1,
MarkdownDescription: `The ` + "`" + `gitlab_project_hook` + "`" + ` resource allows to manage the lifecycle of a project hook.
**Upstream API**: [GitLab REST API docs](https://docs.gitlab.com/api/projects/#hooks)`,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
MarkdownDescription: `The id of the project hook. In the format of "project:hook_id"`,
Computed: true,
},
"project": schema.StringAttribute{
MarkdownDescription: "The name or id of the project to add the hook to.",
Required: true,
},
"project_id": schema.Int64Attribute{
MarkdownDescription: "The id of the project for the hook.",
Computed: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.RequiresReplace(),
},
},
"hook_id": schema.Int64Attribute{
MarkdownDescription: "The id of the project hook.",
Computed: true,
},
"url": schema.StringAttribute{
MarkdownDescription: "The url of the hook to invoke. Forces re-creation to preserve `token`.",
Required: true,
Validators: []validator.String{
stringvalidator.RegexMatches(regexp.MustCompile(`^\S+$`), `The URL may not contain whitespace`),
},
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"token": schema.StringAttribute{
MarkdownDescription: "A token to present when invoking the hook. The token is not available for imported resources.",
Optional: true,
Computed: true,
Sensitive: true,
},
"name": schema.StringAttribute{
MarkdownDescription: "Name of the project webhook.",
Optional: true,
Computed: true,
},
"description": schema.StringAttribute{
MarkdownDescription: "Description of the webhook.",
Optional: true,
Computed: true,
},
"push_events": schema.BoolAttribute{
Description: "Invoke the hook for push events.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(true),
},
"push_events_branch_filter": schema.StringAttribute{
Description: "Invoke the hook for push events on matching branches only.",
Optional: true,
Computed: true,
},
"issues_events": schema.BoolAttribute{
Description: "Invoke the hook for issues events.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
},
"confidential_issues_events": schema.BoolAttribute{
Description: "Invoke the hook for confidential issues events.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
},
"merge_requests_events": schema.BoolAttribute{
Description: "Invoke the hook for merge requests events.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
},
"tag_push_events": schema.BoolAttribute{
Description: "Invoke the hook for tag push events.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
},
"note_events": schema.BoolAttribute{
Description: "Invoke the hook for note events.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
},
"confidential_note_events": schema.BoolAttribute{
Description: "Invoke the hook for confidential note events.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
},
"job_events": schema.BoolAttribute{
Description: "Invoke the hook for job events.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
},
"pipeline_events": schema.BoolAttribute{
Description: "Invoke the hook for pipeline events.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
},
"wiki_page_events": schema.BoolAttribute{
Description: "Invoke the hook for wiki page events.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
},
"resource_access_token_events": schema.BoolAttribute{
Description: "Invoke the hook for project access token expiry events.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
},
"deployment_events": schema.BoolAttribute{
Description: "Invoke the hook for deployment events.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
},
"releases_events": schema.BoolAttribute{
Description: "Invoke the hook for release events.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
},
"enable_ssl_verification": schema.BoolAttribute{
Description: "Enable SSL verification when invoking the hook.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(true),
},
"custom_webhook_template": schema.StringAttribute{
Description: "Custom webhook template.",
Optional: true,
Computed: true,
},
"custom_headers": schema.ListNestedAttribute{
Description: "Custom headers for the project webhook.",
Optional: true,
NestedObject: schema.NestedAttributeObject{
Attributes: map[string]schema.Attribute{
"key": schema.StringAttribute{
Description: "Key of the custom header.",
Required: true,
},
"value": schema.StringAttribute{
Required: true,
Description: "Value of the custom header. This value cannot be imported.",
Sensitive: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
},
},
},
},
},
}
}
// Save the model data from a gitlab.ProjectHook object
func (d *gitlabProjectHookResourceModel) modelToStateModel(a *gitlab.ProjectHook) {
d.URL = types.StringValue(a.URL)
d.ProjectID = types.Int64Value(int64(a.ProjectID))
d.HookID = types.Int64Value(int64(a.ID))
d.Name = types.StringValue(a.Name)
d.Description = types.StringValue(a.Description)
d.PushEvents = types.BoolValue(a.PushEvents)
d.PushEventsBranchFilter = types.StringValue(a.PushEventsBranchFilter)
d.IssuesEvents = types.BoolValue(a.IssuesEvents)
d.ConfidentialIssuesEvents = types.BoolValue(a.ConfidentialIssuesEvents)
d.MergeRequestsEvents = types.BoolValue(a.MergeRequestsEvents)
d.TagPushEvents = types.BoolValue(a.TagPushEvents)
d.NoteEvents = types.BoolValue(a.NoteEvents)
d.ConfidentialNoteEvents = types.BoolValue(a.ConfidentialNoteEvents)
d.JobEvents = types.BoolValue(a.JobEvents)
d.PipelineEvents = types.BoolValue(a.PipelineEvents)
d.WikiPageEvents = types.BoolValue(a.WikiPageEvents)
d.ResourceAccessTokenEvents = types.BoolValue(a.ResourceAccessTokenEvents)
d.DeploymentEvents = types.BoolValue(a.DeploymentEvents)
d.ReleasesEvents = types.BoolValue(a.ReleasesEvents)
d.EnableSSLVerification = types.BoolValue(a.EnableSSLVerification)
d.CustomWebhookTemplate = types.StringValue(a.CustomWebhookTemplate)
if len(a.CustomHeaders) > 0 || len(d.CustomHeaders) > 0 {
// create a map of key/value data from state currently, so we don't overwrite
// values in state when we can't read the values
currentHeaderValues := map[string]string{}
for _, v := range d.CustomHeaders {
currentHeaderValues[v.Key.ValueString()] = v.Value.ValueString()
}
// Iterate through the headers that came back on the hook object, and
// add them to state using the value that already exists in state previously.
// Without this logic, the value would be lost in state with every plan/apply
headers := make([]*gitlabHookCustomHeaderModel, 0, len(a.CustomHeaders))
for _, v := range a.CustomHeaders {
head := &gitlabHookCustomHeaderModel{}
head.Key = types.StringValue(v.Key)
// Value doesn't come back on read requests, so if it's "", we grab the value from
// the current data state instead of the hook, so we don't "lose" the value.
if v.Value != "" {
head.Value = types.StringValue(v.Value)
} else {
head.Value = types.StringValue(currentHeaderValues[v.Key])
}
headers = append(headers, head)
}
d.CustomHeaders = headers
}
}
// Updates the ID from just `hook_id` to `project:hook_id`
// Separated out so it can be tested individually
func (d *gitlabProjectHookResourceModel) v0StateUpgrade(ctx context.Context) {
// The old ID was just the hook ID, and didn't contain the project
oldIdValue := d.ID.ValueStringPointer()
tflog.Debug(ctx, "attempting state migration from V0 to V1 - changing the `id` attribute format", map[string]any{"project": d.Project.ValueString(), "v0-id": oldIdValue})
// Update the ID format and save that back into `data` as the ID
d.ID = types.StringValue(utils.BuildTwoPartID(d.Project.ValueStringPointer(), oldIdValue))
}
func (d *gitlabProjectHookResourceModel) ResourceGitlabProjectHookParseId(id string) (string, int, error) {
project, rawHookId, err := utils.ParseTwoPartID(id)
if err != nil {
return "", 0, err
}
hookId, err := strconv.Atoi(rawHookId)
if err != nil {
return "", 0, err
}
return project, hookId, nil
}