internal/provider/resource_gitlab_branch_protection.go (810 lines of code) (raw):
package provider
import (
"context"
"fmt"
"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/stringdefault"
"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"
)
// Ensure provider defined types fully satisfy framework interfaces
var (
_ resource.Resource = &gitlabBranchProtectionResource{}
_ resource.ResourceWithConfigure = &gitlabBranchProtectionResource{}
_ resource.ResourceWithImportState = &gitlabBranchProtectionResource{}
_ resource.ResourceWithUpgradeState = &gitlabBranchProtectionResource{}
)
func init() {
registerResource(NewGitlabBranchProtectionResource)
}
// NewGitlabBranchProtectionResource is a helper function to simplify the provider implementation.
func NewGitlabBranchProtectionResource() resource.Resource {
return &gitlabBranchProtectionResource{}
}
func (r *gitlabBranchProtectionResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_branch_protection"
}
// gitlabBranchProtectionResource defines the resource implementation
type gitlabBranchProtectionResource struct {
client *gitlab.Client
}
// gitlabBranchProtectionResourceModel describes the resource data model.
type gitlabBranchProtectionResourceModel struct {
Id types.String `tfsdk:"id"`
BranchProtectionId types.Int64 `tfsdk:"branch_protection_id"`
Project types.String `tfsdk:"project"`
Branch types.String `tfsdk:"branch"`
MergeAccessLevel types.String `tfsdk:"merge_access_level"`
PushAccessLevel types.String `tfsdk:"push_access_level"`
UnprotectAccessLevel types.String `tfsdk:"unprotect_access_level"`
AllowForcePush types.Bool `tfsdk:"allow_force_push"`
CodeOwnerApprovalRequired types.Bool `tfsdk:"code_owner_approval_required"`
AllowedToPush []*gitlabBranchProtectionAllowedToPushObjectModel `tfsdk:"allowed_to_push"`
AllowedToMerge []*gitlabBranchProtectionAllowedToObjectModel `tfsdk:"allowed_to_merge"`
AllowedToUnprotect []*gitlabBranchProtectionAllowedToObjectModel `tfsdk:"allowed_to_unprotect"`
}
// gitlabBranchProtectionAllowedToObjectModel describes the generic allowed to block data model.
type gitlabBranchProtectionAllowedToObjectModel struct {
AccessLevel types.String `tfsdk:"access_level"`
AccessLevelDescription types.String `tfsdk:"access_level_description"`
UserId types.Int64 `tfsdk:"user_id"`
GroupId types.Int64 `tfsdk:"group_id"`
}
// gitlabBranchProtectionAllowedToPushObjectModel describes the allowed to push block data model.
type gitlabBranchProtectionAllowedToPushObjectModel struct {
AccessLevel types.String `tfsdk:"access_level"`
AccessLevelDescription types.String `tfsdk:"access_level_description"`
UserId types.Int64 `tfsdk:"user_id"`
GroupId types.Int64 `tfsdk:"group_id"`
DeployKeyId types.Int64 `tfsdk:"deploy_key_id"`
}
func (r *gitlabBranchProtectionResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = r.getV1Schema()
}
func (d *gitlabBranchProtectionResource) getV1Schema() schema.Schema {
return schema.Schema{
Version: 1,
MarkdownDescription: fmt.Sprintf(`The ` + "`gitlab_branch_protection`" + ` resource allows to manage the lifecycle of a protected branch of a repository.
~> **Branch Protection Behavior for the default branch**
Depending on the GitLab instance, group or project setting the default branch of a project is created automatically by GitLab behind the scenes.
Due to [some](https://gitlab.com/gitlab-org/terraform-provider-gitlab/issues/792) [limitations](https://discuss.hashicorp.com/t/ignore-the-order-of-a-complex-typed-list/42242) in the Terraform Provider SDK and the GitLab API,
when creating a new project and trying to manage the branch protection setting for its default branch the ` + "`gitlab_branch_protection`" + ` resource will
automatically take ownership of the default branch without an explicit import by unprotecting and properly protecting it again.
Having multiple ` + "`gitlab_branch_protection`" + ` resources for the same project and default branch will result in them overriding each other - make sure to only have a single one.
This behavior might change in the future.
~> The ` + "`allowed_to_push`" + `, ` + "`allowed_to_merge`" + `, ` + "`allowed_to_unprotect`" + `, ` + "`unprotect_access_level`" + ` and ` + "`code_owner_approval_required`" + ` attributes require a GitLab Enterprise instance.
**Upstream API**: [GitLab REST API docs](https://docs.gitlab.com/api/protected_branches/)`),
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
MarkdownDescription: "The ID of this Terraform resource. In the format of `<project-id:branch>`.",
Computed: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
},
"branch_protection_id": schema.Int64Attribute{
MarkdownDescription: "The ID of the branch protection (not the branch name).",
Computed: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.RequiresReplace(),
int64planmodifier.UseStateForUnknown(),
},
},
"project": schema.StringAttribute{
MarkdownDescription: "The id of the project.",
Required: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
Validators: []validator.String{stringvalidator.LengthAtLeast(1)},
},
"branch": schema.StringAttribute{
MarkdownDescription: "Name of the branch.",
Required: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
Validators: []validator.String{stringvalidator.LengthAtLeast(1)},
},
"merge_access_level": schema.StringAttribute{
MarkdownDescription: fmt.Sprintf("Access levels allowed to merge. Valid values are: %s.", utils.RenderValueListForDocs(api.ValidProtectedBranchTagAccessLevelNames)),
Optional: true,
Computed: true,
Default: stringdefault.StaticString(api.AccessLevelValueToName[gitlab.MaintainerPermissions]),
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
Validators: []validator.String{stringvalidator.OneOf(api.ValidProtectedBranchTagAccessLevelNames...)},
},
"push_access_level": schema.StringAttribute{
MarkdownDescription: fmt.Sprintf("Access levels allowed to push. Valid values are: %s.", utils.RenderValueListForDocs(api.ValidProtectedBranchTagAccessLevelNames)),
Optional: true,
Computed: true,
Default: stringdefault.StaticString(api.AccessLevelValueToName[gitlab.MaintainerPermissions]),
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
Validators: []validator.String{stringvalidator.OneOf(api.ValidProtectedBranchTagAccessLevelNames...)},
},
"unprotect_access_level": schema.StringAttribute{
MarkdownDescription: fmt.Sprintf("Access levels allowed to unprotect. Valid values are: %s.", utils.RenderValueListForDocs(api.ValidProtectedBranchUnprotectAccessLevelNames)),
Optional: true,
Computed: true,
Default: stringdefault.StaticString(api.AccessLevelValueToName[gitlab.MaintainerPermissions]),
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
Validators: []validator.String{stringvalidator.OneOf(api.ValidProtectedBranchUnprotectAccessLevelNames...)},
},
"allow_force_push": schema.BoolAttribute{
MarkdownDescription: "Can be set to true to allow users with push access to force push.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
},
"code_owner_approval_required": schema.BoolAttribute{
MarkdownDescription: "Can be set to true to require code owner approval before merging. Only available for Premium and Ultimate instances.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
},
},
Blocks: map[string]schema.Block{
"allowed_to_push": schemaAllowedToPushBlock(api.ValidProtectedBranchTagAccessLevelNames),
"allowed_to_unprotect": schemaAllowedToBlock("unprotect push", api.ValidProtectedBranchUnprotectAccessLevelNames),
"allowed_to_merge": schemaAllowedToBlock("merge", api.ValidProtectedBranchTagAccessLevelNames),
},
}
}
func schemaAllowedToBlock(action string, validValues []string) schema.Block {
return schema.SetNestedBlock{
MarkdownDescription: fmt.Sprintf("Array of access levels and user(s)/group(s) allowed to %s to protected branch.", action),
NestedObject: schema.NestedBlockObject{
Attributes: map[string]schema.Attribute{
"access_level": schema.StringAttribute{
MarkdownDescription: fmt.Sprintf("Access levels allowed to %s to protected branch. Valid values are: %s.", action,
utils.RenderValueListForDocs(validValues)),
Computed: true,
Validators: []validator.String{
stringvalidator.ExactlyOneOf(path.MatchRelative().AtParent().AtName("user_id"), path.MatchRelative().AtParent().AtName("group_id")),
stringvalidator.OneOf(validValues...),
},
},
"access_level_description": schema.StringAttribute{
Description: "Readable description of access level.",
Computed: true,
},
"user_id": schema.Int64Attribute{
Description: "The ID of a GitLab user allowed to perform the relevant action. Mutually exclusive with `group_id`.",
Optional: true,
},
"group_id": schema.Int64Attribute{
Description: "The ID of a GitLab group allowed to perform the relevant action. Mutually exclusive with `user_id`.",
Optional: true,
},
},
},
}
}
func schemaAllowedToPushBlock(validValues []string) schema.Block {
return schema.SetNestedBlock{
MarkdownDescription: "Array of access levels and user(s)/group(s) allowed to push to protected branch.",
NestedObject: schema.NestedBlockObject{
Attributes: map[string]schema.Attribute{
"access_level": schema.StringAttribute{
MarkdownDescription: fmt.Sprintf("Access levels allowed to push to protected branch. Valid values are: %s.",
utils.RenderValueListForDocs(validValues)),
Computed: true,
Validators: []validator.String{
stringvalidator.ExactlyOneOf(
path.MatchRelative().AtParent().AtName("user_id"),
path.MatchRelative().AtParent().AtName("group_id"),
path.MatchRelative().AtParent().AtName("deploy_key_id"),
),
stringvalidator.OneOf(validValues...),
},
},
"access_level_description": schema.StringAttribute{
Description: "Readable description of access level.",
Computed: true,
},
"user_id": schema.Int64Attribute{
Description: "The ID of a GitLab user allowed to perform the relevant action. Mutually exclusive with `deploy_key_id` and `group_id`.",
Optional: true,
},
"group_id": schema.Int64Attribute{
Description: "The ID of a GitLab group allowed to perform the relevant action. Mutually exclusive with `deploy_key_id` and `user_id`.",
Optional: true,
},
"deploy_key_id": schema.Int64Attribute{
Description: "The ID of a GitLab deploy key allowed to perform the relevant action. Mutually exclusive with `group_id` and `user_id`. This field is read-only until Gitlab 17.5.",
Optional: true,
},
},
},
}
}
// Configure adds the provider configured client to the resource.
func (r *gitlabBranchProtectionResource) 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
}
// Create creates a new upstream resources and adds it into the Terraform state.
func (r *gitlabBranchProtectionResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data *gitlabBranchProtectionResourceModel
// Read Terraform plan data into the model
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// local copies of plan arguments
projectID := data.Project.ValueString()
branch := data.Branch.ValueString()
// call protected repository branch, read Gitlab API to detect if given branch is project default branch and requires default
// branch protection rule removal
existingProtectedBranch, _, err := r.client.ProtectedBranches.GetProtectedBranch(projectID, branch, gitlab.WithContext(ctx))
if err == nil {
projectDetails, _, err := r.client.Projects.GetProject(projectID, nil, gitlab.WithContext(ctx))
if err != nil {
resp.Diagnostics.AddError("GitLab API error occured", fmt.Sprintf("Unable to read project details: %s", err.Error()))
return
}
// Gitlab automatically creates branch protection rule for repository default branch. This results in protection rule existence
// which is not managed by terraform. Then each branch protection rule creation attempt will fail. To fix that it is required
// to firstly remove already existing rule to be able to add provider managed one.
if projectDetails.DefaultBranch == branch {
tflog.Debug(ctx, fmt.Sprintf("This branch protection is for the default branch %q in project %q! It is always "+
"created by gitlab so firstly we heave to unprotect it, because it's not editable ...!", projectID, branch))
_, err := r.client.ProtectedBranches.UnprotectRepositoryBranches(projectID, branch, gitlab.WithContext(ctx))
if err != nil {
resp.Diagnostics.AddError("GitLab API error occurred", fmt.Sprintf("Failed to unprotect default branch %q in "+
"project %q while trying to 'import' it: %v", branch, projectID, err.Error()))
return
}
} else {
resp.Diagnostics.AddError("GitLab API error occurred", fmt.Sprintf("protected branch %q on project %q already exists: %+v",
branch, projectID, *existingProtectedBranch))
return
}
}
pushAccessLevel := api.AccessLevelNameToValue[data.PushAccessLevel.ValueString()]
mergeAccessLevel := api.AccessLevelNameToValue[data.MergeAccessLevel.ValueString()]
unprotectAccessLevel := api.AccessLevelNameToValue[data.UnprotectAccessLevel.ValueString()]
allowedToPush := generateAllowedToPushStateToAccessLevels([]*gitlab.BranchAccessDescription{}, data.AllowedToPush)
allowedToMerge := generateAllowedToStateToAccessLevels([]*gitlab.BranchAccessDescription{}, data.AllowedToMerge)
allowedToUnprotect := generateAllowedToStateToAccessLevels([]*gitlab.BranchAccessDescription{}, data.AllowedToUnprotect)
// configure GitLab protected branch creation API call
options := gitlab.ProtectRepositoryBranchesOptions{
Name: gitlab.Ptr(data.Branch.ValueString()),
PushAccessLevel: &pushAccessLevel,
MergeAccessLevel: &mergeAccessLevel,
UnprotectAccessLevel: &unprotectAccessLevel,
AllowForcePush: gitlab.Ptr(data.AllowForcePush.ValueBool()),
CodeOwnerApprovalRequired: data.CodeOwnerApprovalRequired.ValueBoolPointer(),
AllowedToPush: &allowedToPush,
AllowedToMerge: &allowedToMerge,
AllowedToUnprotect: &allowedToUnprotect,
}
// validate attribute compliance with license model
if !areCreateAttributesCompliantWithLicenseModel(r.client, options, resp) {
return
}
// call Gitlab protected repository branch creation API
protectedBranch, _, err := r.client.ProtectedBranches.ProtectRepositoryBranches(projectID, &options, gitlab.WithContext(ctx))
if err != nil {
resp.Diagnostics.AddError("GitLab API error occurred", fmt.Sprintf("Unable to protect repository branch: %s", err.Error()))
return
}
// Create resource ID and persist in state model
data.Id = types.StringValue(utils.BuildTwoPartID(&projectID, &protectedBranch.Name))
// persist API response in state model
r.protectedBranchToStateModel(projectID, protectedBranch, data)
// Save data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
// Log the creation of the resource
tflog.Debug(ctx, "Created a protected branch", map[string]any{
"project_id": data.Project.ValueString(), "branch": data.Branch.ValueString(),
})
}
// Read refreshes the Terraform state with the latest data.
func (r *gitlabBranchProtectionResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var data *gitlabBranchProtectionResourceModel
// Read Terraform prior state data into the model
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// read all information for refresh from resource id
projectID, branch, err := utils.ParseTwoPartID(data.Id.ValueString())
if err != nil {
resp.Diagnostics.AddError(
"Invalid resource ID format",
fmt.Sprintf("The resource ID '%s' has an invalid format. It should be '<project-id>:<branch>'. Error: %s", data.Id.ValueString(), err.Error()),
)
return
}
// call protected repository branch, read Gitlab API
protectedBranch, _, err := r.client.ProtectedBranches.GetProtectedBranch(projectID, branch, gitlab.WithContext(ctx))
if err != nil {
if api.Is404(err) {
tflog.Debug(ctx, "protected branch does not exist, removing from state", map[string]any{
"project_id": projectID, "branch": branch,
})
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.AddError("GitLab API error occured", fmt.Sprintf("Unable to read protected branch details: %s", err.Error()))
return
}
// persist API response in state model
r.protectedBranchToStateModel(projectID, protectedBranch, data)
// Save updated data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
// Updates updates the resource in-place.
func (r *gitlabBranchProtectionResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var data *gitlabBranchProtectionResourceModel
// Read Terraform plan data into the model
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// local copies of plan arguments
projectID := data.Project.ValueString()
branch := data.Branch.ValueString()
// call read, protected repository branch API to retrieve current unprotected access levels
protectedBranch, _, err := r.client.ProtectedBranches.GetProtectedBranch(projectID, branch, gitlab.WithContext(ctx))
if err != nil {
if api.Is404(err) {
tflog.Debug(ctx, "protected branch does not exist, removing from state", map[string]any{
"project_id": projectID, "branch": branch,
})
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.AddError("GitLab API error occured", fmt.Sprintf("Unable to read protected branch details: %s", err.Error()))
return
}
allowedToPush := generateAllowedToPushStateToAccessLevels(protectedBranch.PushAccessLevels, data.AllowedToPush)
allowedToMerge := generateAllowedToStateToAccessLevels(protectedBranch.MergeAccessLevels, data.AllowedToMerge)
allowedToUnprotect := generateAllowedToStateToAccessLevels(protectedBranch.UnprotectAccessLevels, data.AllowedToUnprotect)
// configure protect repository branch update GitLab API call
options := gitlab.UpdateProtectedBranchOptions{
Name: gitlab.Ptr(branch),
AllowForcePush: gitlab.Ptr(data.AllowForcePush.ValueBool()),
CodeOwnerApprovalRequired: data.CodeOwnerApprovalRequired.ValueBoolPointer(),
AllowedToPush: &allowedToPush,
AllowedToMerge: &allowedToMerge,
AllowedToUnprotect: &allowedToUnprotect,
}
// validate attribute compliance with license model
if !areUpdateAttributesCompliantWithLicenseModel(r.client, options, resp) {
return
}
// call Gitlab protected repository branch update API
updatedProtectedBranch, _, err := r.client.ProtectedBranches.UpdateProtectedBranch(projectID, branch, &options, gitlab.WithContext(ctx))
if err != nil {
resp.Diagnostics.AddError("GitLab API error occurred", fmt.Sprintf("Unable to update protected repository branch: %s", err.Error()))
return
}
// Create resource ID and persist in state model
data.Id = types.StringValue(utils.BuildTwoPartID(&projectID, &branch))
// persist API response in state model
r.protectedBranchToStateModel(projectID, updatedProtectedBranch, data)
// Save data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
// Log the update of the resource
tflog.Debug(ctx, "Updated a protected branch", map[string]any{
"project_id": projectID, "branch": branch,
})
}
// Deletes removes the resource.
func (r *gitlabBranchProtectionResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var data *gitlabBranchProtectionResourceModel
// Read Terraform plan data into the model
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// call Gitlab unprotect repository branch API
_, err := r.client.ProtectedBranches.UnprotectRepositoryBranches(data.Project.ValueString(), data.Branch.ValueString(), gitlab.WithContext(ctx))
if err != nil {
resp.Diagnostics.AddError("Failed to delete gitlab branch protection", err.Error())
return
}
tflog.Debug(ctx, "Delete gitlab protected branch", map[string]any{
"project_id": data.Project.ValueString(), "branch": data.Branch.ValueString(),
})
}
// ImportState imports the resource into the Terraform state.
func (r *gitlabBranchProtectionResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}
func (d *gitlabBranchProtectionResource) UpgradeState(context.Context) map[int64]resource.StateUpgrader {
// The v0 schema. Needed so the pointer can be used.
schema := d.getV0Schema()
return map[int64]resource.StateUpgrader{
0: {
PriorSchema: &schema,
StateUpgrader: func(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) {
var data *gitlabBranchProtectionResourceModelv0
// Read Terraform plan data into the model
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// Move all data to the new struct values. Note: Only values that were present at v0 time
// are included here, because they couldn't exist as known in the old struct.
project_id := data.Project.ValueString()
branch := data.Branch.ValueString()
resource_id := utils.BuildTwoPartID(&project_id, &branch)
newData := &gitlabBranchProtectionResourceModel{
Id: types.StringValue(resource_id),
BranchProtectionId: data.BranchProtectionId,
Project: data.Project,
Branch: data.Branch,
MergeAccessLevel: data.MergeAccessLevel,
PushAccessLevel: data.PushAccessLevel,
UnprotectAccessLevel: data.UnprotectAccessLevel,
AllowForcePush: data.AllowForcePush,
AllowedToPush: migrateAllowedToBlock(data.AllowedToPush),
AllowedToMerge: data.AllowedToMerge,
AllowedToUnprotect: data.AllowedToUnprotect,
CodeOwnerApprovalRequired: data.CodeOwnerApprovalRequired,
}
resp.Diagnostics.Append(resp.State.Set(ctx, &newData)...)
tflog.Debug(ctx, "Upgraded gitlab_branch_protection resource from v0 to v1 verion", map[string]any{
"project_id": data.Project.ValueString(), "branch": data.Branch.ValueString(),
})
},
},
}
}
func areCreateAttributesCompliantWithLicenseModel(client *gitlab.Client, pbOptions gitlab.ProtectRepositoryBranchesOptions, resp *resource.CreateResponse) bool {
// part of functionalities are available only for enterpise licensing model. To check if determine license model
ee_context, err := utils.IsRunningInEEContext(client)
if err != nil {
resp.Diagnostics.AddError("GitLab API error occurred", fmt.Sprintf("Unable to get gitlab server metadata details: %s", err.Error()))
return false
}
// if license model is not enterpise make sure that no enterprise features are set
if !ee_context {
if len(*pbOptions.AllowedToPush) > 0 {
resp.Diagnostics.AddError("feature unavailable `allowed_to_push`", "Enterprise license required")
return false
}
if len(*pbOptions.AllowedToMerge) > 0 {
resp.Diagnostics.AddError("feature unavailable `allowed_to_merge`", "Enterprise license required")
return false
}
if len(*pbOptions.AllowedToUnprotect) > 0 {
resp.Diagnostics.AddError("feature unavailable `allowed_to_unprotect`", "Enterprise license required")
return false
}
if *pbOptions.CodeOwnerApprovalRequired {
resp.Diagnostics.AddError("feature unavailable `code_owner_approval_required`", "Enterprise license required")
return false
}
}
return true
}
func areUpdateAttributesCompliantWithLicenseModel(client *gitlab.Client, pbOptions gitlab.UpdateProtectedBranchOptions, resp *resource.UpdateResponse) bool {
// part of functionalities are available only for enterpise licensing model. To check if determine license model
ee_context, err := utils.IsRunningInEEContext(client)
if err != nil {
resp.Diagnostics.AddError("GitLab API error occurred", fmt.Sprintf("Unable to get gitlab server metadata details: %s", err.Error()))
return false
}
// if license model is not enterpise make sure that no enterprise features are set
if !ee_context {
if len(*pbOptions.AllowedToPush) > 0 {
resp.Diagnostics.AddError("feature unavailable `allowed_to_push`", "Enterprise license required")
return false
}
if len(*pbOptions.AllowedToMerge) > 0 {
resp.Diagnostics.AddError("feature unavailable `allowed_to_merge`", "Enterprise license required")
return false
}
if len(*pbOptions.AllowedToUnprotect) > 0 {
resp.Diagnostics.AddError("feature unavailable `allowed_to_unprotect`", "Enterprise license required")
return false
}
if *pbOptions.CodeOwnerApprovalRequired {
resp.Diagnostics.AddError("feature unavailable `code_owner_approval_required`", "Enterprise license required")
return false
}
}
return true
}
// When accessing protectedBranch.*AccessLevels argument by gitlab API 'ProtectedBranches.GetProtectedBranch' list contains
// elements with AccessLevel field defined and elements connected with user_id&group_id which does not have it. Then it can
// result in setting AccessLevel for null value which will result in error. To prohibite such cases only elements with
// AccessLevel field set are returned
func firstValidAccessLevel(descriptions []*gitlab.BranchAccessDescription) (*gitlab.AccessLevelValue, error) {
for _, description := range descriptions {
if description.UserID != 0 || description.GroupID != 0 {
continue
}
return &description.AccessLevel, nil
}
return nil, fmt.Errorf("no valid access level found")
}
func generateAllowedToStateToAccessLevels(currentAllowedTos []*gitlab.BranchAccessDescription, plannedAllowedTos []*gitlabBranchProtectionAllowedToObjectModel) []*gitlab.BranchPermissionOptions {
validCurrentAllowedTos := []*gitlab.BranchAccessDescription{}
// retrieve only elements assigned to user(s) or group(s)
for _, currentAllowedTo := range currentAllowedTos {
if currentAllowedTo.UserID != 0 || currentAllowedTo.GroupID != 0 {
validCurrentAllowedTos = append(validCurrentAllowedTos, currentAllowedTo)
}
}
finalAllowedTo := []*gitlab.BranchPermissionOptions{}
//detect entities to be created
for _, plannedAllowedTo := range plannedAllowedTos {
var allowedToBranchPermissionOptionData *gitlab.BranchPermissionOptions = populateBranchPermissionOptionsData(validCurrentAllowedTos, plannedAllowedTo)
if allowedToBranchPermissionOptionData != nil {
finalAllowedTo = append(finalAllowedTo, allowedToBranchPermissionOptionData)
}
}
//detect entities to be removed
for _, validCurrentAllowedTo := range validCurrentAllowedTos {
requireRemoval := true
for _, plannedAllowedTo := range plannedAllowedTos {
// if element exists in planned values skip removal
if !plannedAllowedTo.UserId.IsNull() && plannedAllowedTo.UserId.ValueInt64() == int64(validCurrentAllowedTo.UserID) ||
!plannedAllowedTo.GroupId.IsNull() && plannedAllowedTo.GroupId.ValueInt64() == int64(validCurrentAllowedTo.GroupID) {
requireRemoval = false
continue
}
}
if requireRemoval {
requireRemovalBranchPermissionOptionData := &gitlab.BranchPermissionOptions{
ID: &validCurrentAllowedTo.ID,
Destroy: gitlab.Ptr(true),
}
finalAllowedTo = append(finalAllowedTo, requireRemovalBranchPermissionOptionData)
}
}
return finalAllowedTo
}
func generateAllowedToPushStateToAccessLevels(currentAllowedTos []*gitlab.BranchAccessDescription, plannedAllowedTos []*gitlabBranchProtectionAllowedToPushObjectModel) []*gitlab.BranchPermissionOptions {
validCurrentAllowedTos := []*gitlab.BranchAccessDescription{}
// retrieve only elements assigned to user(s) or group(s)
for _, currentAllowedTo := range currentAllowedTos {
if currentAllowedTo.UserID != 0 || currentAllowedTo.GroupID != 0 || currentAllowedTo.DeployKeyID != 0 {
validCurrentAllowedTos = append(validCurrentAllowedTos, currentAllowedTo)
}
}
finalAllowedTo := []*gitlab.BranchPermissionOptions{}
//detect entities to be created
for _, plannedAllowedTo := range plannedAllowedTos {
var allowedToBranchPermissionOptionData *gitlab.BranchPermissionOptions = populateBranchPermissionOptionsDataForPush(validCurrentAllowedTos, plannedAllowedTo)
if allowedToBranchPermissionOptionData != nil {
finalAllowedTo = append(finalAllowedTo, allowedToBranchPermissionOptionData)
}
}
//detect entities to be removed
for _, validCurrentAllowedTo := range validCurrentAllowedTos {
requireRemoval := true
for _, plannedAllowedTo := range plannedAllowedTos {
// if element exists in planned values skip removal
if !plannedAllowedTo.UserId.IsNull() && plannedAllowedTo.UserId.ValueInt64() == int64(validCurrentAllowedTo.UserID) ||
!plannedAllowedTo.GroupId.IsNull() && plannedAllowedTo.GroupId.ValueInt64() == int64(validCurrentAllowedTo.GroupID) ||
!plannedAllowedTo.DeployKeyId.IsNull() && plannedAllowedTo.DeployKeyId.ValueInt64() == int64(validCurrentAllowedTo.DeployKeyID) {
requireRemoval = false
continue
}
}
if requireRemoval {
requireRemovalBranchPermissionOptionData := &gitlab.BranchPermissionOptions{
ID: &validCurrentAllowedTo.ID,
Destroy: gitlab.Ptr(true),
}
finalAllowedTo = append(finalAllowedTo, requireRemovalBranchPermissionOptionData)
}
}
return finalAllowedTo
}
func populateBranchPermissionOptionsData(currentAllowedTos []*gitlab.BranchAccessDescription, allowedTo *gitlabBranchProtectionAllowedToObjectModel) *gitlab.BranchPermissionOptions {
var allowedToBranchPermissionOptionData *gitlab.BranchPermissionOptions
requireCreation := true
//detect if element already exists
for _, currentAllowedTo := range currentAllowedTos {
//if element already exists skip creation
if allowedTo.AccessLevel == types.StringValue(api.AccessLevelValueToName[currentAllowedTo.AccessLevel]) ||
!allowedTo.UserId.IsNull() && allowedTo.UserId.ValueInt64() == int64(currentAllowedTo.UserID) ||
!allowedTo.GroupId.IsNull() && allowedTo.GroupId.ValueInt64() == int64(currentAllowedTo.GroupID) {
requireCreation = false
}
}
if requireCreation {
allowedToBranchPermissionOptionData = &gitlab.BranchPermissionOptions{}
if !allowedTo.AccessLevel.IsNull() && allowedTo.AccessLevel.ValueString() != "" {
allowedToBranchPermissionOptionData.AccessLevel = gitlab.Ptr(api.AccessLevelNameToValue[allowedTo.AccessLevel.ValueString()])
}
if !allowedTo.UserId.IsNull() && allowedTo.UserId.ValueInt64() != 0 {
allowedToBranchPermissionOptionData.UserID = gitlab.Ptr(int(allowedTo.UserId.ValueInt64()))
}
if !allowedTo.GroupId.IsNull() && allowedTo.GroupId.ValueInt64() != 0 {
allowedToBranchPermissionOptionData.GroupID = gitlab.Ptr(int(allowedTo.GroupId.ValueInt64()))
}
}
return allowedToBranchPermissionOptionData
}
func populateBranchPermissionOptionsDataForPush(currentAllowedTos []*gitlab.BranchAccessDescription, allowedTo *gitlabBranchProtectionAllowedToPushObjectModel) *gitlab.BranchPermissionOptions {
var allowedToBranchPermissionOptionData *gitlab.BranchPermissionOptions
requireCreation := true
//detect if element already exists
for _, currentAllowedTo := range currentAllowedTos {
//if element already exists skip creation
if allowedTo.AccessLevel == types.StringValue(api.AccessLevelValueToName[currentAllowedTo.AccessLevel]) ||
!allowedTo.UserId.IsNull() && allowedTo.UserId.ValueInt64() == int64(currentAllowedTo.UserID) ||
!allowedTo.GroupId.IsNull() && allowedTo.GroupId.ValueInt64() == int64(currentAllowedTo.GroupID) ||
!allowedTo.DeployKeyId.IsNull() && allowedTo.DeployKeyId.ValueInt64() == int64(currentAllowedTo.DeployKeyID) {
requireCreation = false
}
}
if requireCreation {
allowedToBranchPermissionOptionData = &gitlab.BranchPermissionOptions{}
if !allowedTo.AccessLevel.IsNull() && allowedTo.AccessLevel.ValueString() != "" {
allowedToBranchPermissionOptionData.AccessLevel = gitlab.Ptr(api.AccessLevelNameToValue[allowedTo.AccessLevel.ValueString()])
}
if !allowedTo.UserId.IsNull() && allowedTo.UserId.ValueInt64() != 0 {
allowedToBranchPermissionOptionData.UserID = gitlab.Ptr(int(allowedTo.UserId.ValueInt64()))
}
if !allowedTo.GroupId.IsNull() && allowedTo.GroupId.ValueInt64() != 0 {
allowedToBranchPermissionOptionData.GroupID = gitlab.Ptr(int(allowedTo.GroupId.ValueInt64()))
}
if !allowedTo.DeployKeyId.IsNull() && allowedTo.DeployKeyId.ValueInt64() != 0 {
allowedToBranchPermissionOptionData.DeployKeyID = gitlab.Ptr(int(allowedTo.DeployKeyId.ValueInt64()))
}
}
return allowedToBranchPermissionOptionData
}
func populateAllowedToToStateModel(accessLevels []*gitlab.BranchAccessDescription) []*gitlabBranchProtectionAllowedToObjectModel {
valid_access_levels := []*gitlab.BranchAccessDescription{}
for i := range accessLevels {
if accessLevels[i].UserID != 0 || accessLevels[i].GroupID != 0 {
valid_access_levels = append(valid_access_levels, accessLevels[i])
}
}
if len(valid_access_levels) == 0 {
return nil
}
return populateAllowedToObjectList(valid_access_levels)
}
func populateAllowedToPushToStateModel(accessLevels []*gitlab.BranchAccessDescription) []*gitlabBranchProtectionAllowedToPushObjectModel {
valid_access_levels := []*gitlab.BranchAccessDescription{}
for i := range accessLevels {
if accessLevels[i].UserID != 0 || accessLevels[i].GroupID != 0 || accessLevels[i].DeployKeyID != 0 {
valid_access_levels = append(valid_access_levels, accessLevels[i])
}
}
if len(valid_access_levels) == 0 {
return nil
}
return populateAllowedToPushObjectList(valid_access_levels)
}
func populateAllowedToObjectList(access_levels []*gitlab.BranchAccessDescription) []*gitlabBranchProtectionAllowedToObjectModel {
allowedTosData := make([]*gitlabBranchProtectionAllowedToObjectModel, len(access_levels))
for i, v := range access_levels {
allowedToData := gitlabBranchProtectionAllowedToObjectModel{
AccessLevelDescription: types.StringValue(v.AccessLevelDescription),
}
allowedToData.AccessLevel = types.StringValue(api.AccessLevelValueToName[v.AccessLevel])
if v.UserID != 0 {
allowedToData.UserId = types.Int64Value(int64(v.UserID))
}
if v.GroupID != 0 {
allowedToData.GroupId = types.Int64Value(int64(v.GroupID))
}
allowedTosData[i] = &allowedToData
}
return allowedTosData
}
func populateAllowedToPushObjectList(access_levels []*gitlab.BranchAccessDescription) []*gitlabBranchProtectionAllowedToPushObjectModel {
allowedTosData := make([]*gitlabBranchProtectionAllowedToPushObjectModel, len(access_levels))
for i, v := range access_levels {
allowedToData := gitlabBranchProtectionAllowedToPushObjectModel{
AccessLevelDescription: types.StringValue(v.AccessLevelDescription),
}
allowedToData.AccessLevel = types.StringValue(api.AccessLevelValueToName[v.AccessLevel])
if v.UserID != 0 {
allowedToData.UserId = types.Int64Value(int64(v.UserID))
}
if v.GroupID != 0 {
allowedToData.GroupId = types.Int64Value(int64(v.GroupID))
}
if v.DeployKeyID != 0 {
allowedToData.DeployKeyId = types.Int64Value(int64(v.DeployKeyID))
}
allowedTosData[i] = &allowedToData
}
return allowedTosData
}
func (r *gitlabBranchProtectionResource) protectedBranchToStateModel(projectID string, protectedBranch *gitlab.ProtectedBranch, data *gitlabBranchProtectionResourceModel) {
data.Project = types.StringValue(projectID)
data.Branch = types.StringValue(protectedBranch.Name)
if mergeAccessLevel, err := firstValidAccessLevel(protectedBranch.MergeAccessLevels); err == nil {
data.MergeAccessLevel = types.StringValue(api.AccessLevelValueToName[*mergeAccessLevel])
}
if pushAccessLevel, err := firstValidAccessLevel(protectedBranch.PushAccessLevels); err == nil {
data.PushAccessLevel = types.StringValue(api.AccessLevelValueToName[*pushAccessLevel])
}
if unprotectAccessLevel, err := firstValidAccessLevel(protectedBranch.UnprotectAccessLevels); err == nil {
data.UnprotectAccessLevel = types.StringValue(api.AccessLevelValueToName[*unprotectAccessLevel])
}
data.AllowForcePush = types.BoolValue(protectedBranch.AllowForcePush)
data.AllowedToPush = populateAllowedToPushToStateModel(protectedBranch.PushAccessLevels)
data.AllowedToMerge = populateAllowedToToStateModel(protectedBranch.MergeAccessLevels)
data.AllowedToUnprotect = populateAllowedToToStateModel(protectedBranch.UnprotectAccessLevels)
data.CodeOwnerApprovalRequired = types.BoolValue(protectedBranch.CodeOwnerApprovalRequired)
data.BranchProtectionId = types.Int64Value(int64(protectedBranch.ID))
}
//////////////////////////////////////////////////////////////////
// resource schema verion v0 //
//////////////////////////////////////////////////////////////////
// gitlabBranchProtectionResourceModelv0 describes the resource data model in verison v0.
type gitlabBranchProtectionResourceModelv0 struct {
Project types.String `tfsdk:"project"`
Branch types.String `tfsdk:"branch"`
MergeAccessLevel types.String `tfsdk:"merge_access_level"`
PushAccessLevel types.String `tfsdk:"push_access_level"`
UnprotectAccessLevel types.String `tfsdk:"unprotect_access_level"`
AllowForcePush types.Bool `tfsdk:"allow_force_push"`
CodeOwnerApprovalRequired types.Bool `tfsdk:"code_owner_approval_required"`
BranchProtectionId types.Int64 `tfsdk:"branch_protection_id"`
AllowedToPush []*gitlabBranchProtectionAllowedToObjectModel `tfsdk:"allowed_to_push"`
AllowedToMerge []*gitlabBranchProtectionAllowedToObjectModel `tfsdk:"allowed_to_merge"`
AllowedToUnprotect []*gitlabBranchProtectionAllowedToObjectModel `tfsdk:"allowed_to_unprotect"`
}
// gitlabBranchProtectionResource describes the resource schema in version v0.
func (d *gitlabBranchProtectionResource) getV0Schema() schema.Schema {
return schema.Schema{
Version: 0,
MarkdownDescription: fmt.Sprintf(`The ` + "`gitlab_branch_protection`" + ` resource allows to manage the lifecycle of a protected branch of a repository.
~> **Branch Protection Behavior for the default branch**
Depending on the GitLab instance, group or project setting the default branch of a project is created automatically by GitLab behind the scenes.
Due to [some](https://gitlab.com/gitlab-org/terraform-provider-gitlab/issues/792) [limitations](https://discuss.hashicorp.com/t/ignore-the-order-of-a-complex-typed-list/42242) in the Terraform Provider SDK and the GitLab API,
when creating a new project and trying to manage the branch protection setting for its default branch the ` + "`gitlab_branch_protection`" + ` resource will
automatically take ownership of the default branch without an explicit import by unprotecting and properly protecting it again.
Having multiple ` + "`gitlab_branch_protection`" + ` resources for the same project and default branch will result in them overriding each other - make sure to only have a single one.
This behavior might change in the future.
~> The ` + "`allowed_to_push`" + `, ` + "`allowed_to_merge`" + `, ` + "`allowed_to_unprotect`" + `, ` + "`unprotect_access_level`" + ` and ` + "`code_owner_approval_required`" + ` attributes require a GitLab Enterprise instance.
**Upstream API**: [GitLab REST API docs](https://docs.gitlab.com/api/protected_branches/)`),
Attributes: map[string]schema.Attribute{
"branch_protection_id": schema.Int64Attribute{
MarkdownDescription: "The ID of the branch protection (not the branch name).",
Computed: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.RequiresReplace(),
int64planmodifier.UseStateForUnknown(),
},
},
"project": schema.StringAttribute{
MarkdownDescription: "The id of the project.",
Required: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
Validators: []validator.String{stringvalidator.LengthAtLeast(1)},
},
"branch": schema.StringAttribute{
MarkdownDescription: "Name of the branch.",
Required: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
Validators: []validator.String{stringvalidator.LengthAtLeast(1)},
},
"merge_access_level": schema.StringAttribute{
MarkdownDescription: fmt.Sprintf("Access levels allowed to merge. Valid values are: %s.", utils.RenderValueListForDocs(api.ValidProtectedBranchTagAccessLevelNames)),
Optional: true,
Computed: true,
Default: stringdefault.StaticString(api.AccessLevelValueToName[gitlab.MaintainerPermissions]),
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
Validators: []validator.String{stringvalidator.OneOf(api.ValidProtectedBranchTagAccessLevelNames...)},
},
"push_access_level": schema.StringAttribute{
MarkdownDescription: fmt.Sprintf("Access levels allowed to push. Valid values are: %s.", utils.RenderValueListForDocs(api.ValidProtectedBranchTagAccessLevelNames)),
Optional: true,
Computed: true,
Default: stringdefault.StaticString(api.AccessLevelValueToName[gitlab.MaintainerPermissions]),
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
Validators: []validator.String{stringvalidator.OneOf(api.ValidProtectedBranchTagAccessLevelNames...)},
},
"unprotect_access_level": schema.StringAttribute{
MarkdownDescription: fmt.Sprintf("Access levels allowed to unprotect. Valid values are: %s.", utils.RenderValueListForDocs(api.ValidProtectedBranchUnprotectAccessLevelNames)),
Optional: true,
Computed: true,
Default: stringdefault.StaticString(api.AccessLevelValueToName[gitlab.MaintainerPermissions]),
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
Validators: []validator.String{stringvalidator.OneOf(api.ValidProtectedBranchUnprotectAccessLevelNames...)},
},
"allow_force_push": schema.BoolAttribute{
MarkdownDescription: "Can be set to true to allow users with push access to force push.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
},
"code_owner_approval_required": schema.BoolAttribute{
MarkdownDescription: "Can be set to true to require code owner approval before merging. Only available for Premium and Ultimate instances.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
},
},
Blocks: map[string]schema.Block{
"allowed_to_push": schemaAllowedToObject("push", api.ValidProtectedBranchTagAccessLevelNames),
"allowed_to_merge": schemaAllowedToObject("merge", api.ValidProtectedBranchTagAccessLevelNames),
"allowed_to_unprotect": schemaAllowedToObject("unprotect push", api.ValidProtectedBranchUnprotectAccessLevelNames),
},
}
}
func schemaAllowedToObject(action string, validValues []string) schema.Block {
return schema.SetNestedBlock{
MarkdownDescription: fmt.Sprintf("Array of access levels and user(s)/group(s) allowed to %s to protected branch.", action),
NestedObject: schema.NestedBlockObject{
Attributes: map[string]schema.Attribute{
"access_level": schema.StringAttribute{
MarkdownDescription: fmt.Sprintf("Access levels allowed to %s to protected branch. Valid values are: %s.", action,
utils.RenderValueListForDocs(validValues)),
Computed: true,
Validators: []validator.String{
stringvalidator.ExactlyOneOf(path.MatchRelative().AtParent().AtName("user_id"), path.MatchRelative().AtParent().AtName("group_id")),
stringvalidator.OneOf(validValues...),
},
},
"access_level_description": schema.StringAttribute{
Description: "Readable description of access level.",
Computed: true,
},
"user_id": schema.Int64Attribute{
Description: "The ID of a GitLab user allowed to perform the relevant action. Mutually exclusive with `group_id`.",
Optional: true,
},
"group_id": schema.Int64Attribute{
Description: "The ID of a GitLab group allowed to perform the relevant action. Mutually exclusive with `user_id`.",
Optional: true,
},
},
},
}
}
func migrateAllowedToBlock(previousData []*gitlabBranchProtectionAllowedToObjectModel) []*gitlabBranchProtectionAllowedToPushObjectModel {
newData := make([]*gitlabBranchProtectionAllowedToPushObjectModel, len(previousData))
for i, v := range previousData {
newData[i] = &gitlabBranchProtectionAllowedToPushObjectModel{
AccessLevel: v.AccessLevel,
AccessLevelDescription: v.AccessLevelDescription,
UserId: v.UserId,
GroupId: v.GroupId,
}
}
return newData
}