internal/provider/sdk/resource_gitlab_group.go (1,023 lines of code) (raw):

package sdk import ( "context" "fmt" "strings" "time" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" 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" ) // Values to be used for validation and documentation var ( defaultBranchProtectionValues = []int{0, 1, 2, 3, 4} defaultBranchProtectionDefaultsValues = []string{api.AccessLevelValueToName[gitlab.DeveloperPermissions], api.AccessLevelValueToName[gitlab.MaintainerPermissions], api.AccessLevelValueToName[gitlab.NoPermissions]} visibilityLevelValues = []string{"private", "internal", "public"} projectCreationLevelValues = []string{string(gitlab.NoOneProjectCreation), string(gitlab.OwnerProjectCreation), string(gitlab.MaintainerProjectCreation), string(gitlab.DeveloperProjectCreation)} subGroupCreationLevelValues = []string{string(gitlab.OwnerSubGroupCreationLevelValue), string(gitlab.MaintainerSubGroupCreationLevelValue)} validSharedRunnersSettings = []string{ "enabled", "disabled_and_overridable", "disabled_and_unoverridable", // Deprecated "disabled_with_override", } ) var _ = registerResource("gitlab_group", func() *schema.Resource { return &schema.Resource{ Description: `The ` + "`gitlab_group`" + ` resource allows to manage the lifecycle of a group. -> On GitLab.com, you cannot use the ` + "`gitlab_group`" + ` resource to create a [top-level group](https://docs.gitlab.com/user/group/#group-hierarchy). Instead, you must [create a group](https://docs.gitlab.com/user/group/#create-a-group) in the UI, then import the group into your Terraform configuration. From here, you can manage the group using the Terraform Provider. **Upstream API**: [GitLab REST API docs](https://docs.gitlab.com/api/groups/)`, CreateContext: resourceGitlabGroupCreate, ReadContext: resourceGitlabGroupRead, UpdateContext: resourceGitlabGroupUpdate, DeleteContext: resourceGitlabGroupDelete, Importer: &schema.ResourceImporter{ StateContext: schema.ImportStatePassthroughContext, }, Schema: constructSchema(map[string]*schema.Schema{ "name": { Description: "The name of the group.", Type: schema.TypeString, Required: true, }, "path": { Description: "The path of the group.", Type: schema.TypeString, Required: true, }, "full_path": { Description: "The full path of the group.", Type: schema.TypeString, Computed: true, }, "full_name": { Description: "The full name of the group.", Type: schema.TypeString, Computed: true, }, "web_url": { Description: "Web URL of the group.", Type: schema.TypeString, Computed: true, }, "description": { Description: "The group's description.", Type: schema.TypeString, Optional: true, }, "lfs_enabled": { Description: "Enable/disable Large File Storage (LFS) for the projects in this group.", Type: schema.TypeBool, Optional: true, Computed: true, }, "default_branch": { Description: "Initial default branch name.", Type: schema.TypeString, Optional: true, }, "default_branch_protection": { Description: fmt.Sprintf("See https://docs.gitlab.com/api/groups/#options-for-default_branch_protection. Valid values are: %s.", utils.RenderIntValueListForDocs(defaultBranchProtectionValues)), Type: schema.TypeInt, Optional: true, Computed: true, ValidateFunc: validation.IntInSlice(defaultBranchProtectionValues), Deprecated: "Deprecated in GitLab 17.0, due for removal in v5 of the API. Use default_branch_protection_defaults instead.", ConflictsWith: []string{ "default_branch_protection_defaults", }, }, "default_branch_protection_defaults": { Description: "The default branch protection defaults", Type: schema.TypeList, MaxItems: 1, Optional: true, Computed: true, ConflictsWith: []string{ "default_branch_protection", }, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "allowed_to_push": { Description: fmt.Sprintf("An array of access levels allowed to push. Valid values are: %s.", utils.RenderValueListForDocs(defaultBranchProtectionDefaultsValues)), Type: schema.TypeList, Optional: true, Computed: true, Elem: &schema.Schema{ Type: schema.TypeString, ValidateFunc: validation.StringInSlice(defaultBranchProtectionDefaultsValues, false), }, }, "allow_force_push": { Description: "Allow force push for all users with push access.", Type: schema.TypeBool, Optional: true, Computed: true, }, "allowed_to_merge": { Description: fmt.Sprintf("An array of access levels allowed to merge. Valid values are: %s.", utils.RenderValueListForDocs(defaultBranchProtectionDefaultsValues)), Type: schema.TypeList, Optional: true, Computed: true, Elem: &schema.Schema{ Type: schema.TypeString, ValidateFunc: validation.StringInSlice(defaultBranchProtectionDefaultsValues, false), }, }, "developer_can_initial_push": { Description: "Allow developers to initial push.", Type: schema.TypeBool, Optional: true, Computed: true, }, }, }, }, "request_access_enabled": { Description: "Allow users to request member access.", Type: schema.TypeBool, Optional: true, Computed: true, }, "visibility_level": { Description: fmt.Sprintf("The group's visibility. Can be `private`, `internal`, or `public`. Valid values are: %s.", utils.RenderValueListForDocs(visibilityLevelValues)), Type: schema.TypeString, Optional: true, Computed: true, ValidateFunc: validation.StringInSlice(visibilityLevelValues, true), }, "share_with_group_lock": { Description: "Prevent sharing a project with another group within this group.", Type: schema.TypeBool, Optional: true, Computed: true, }, "project_creation_level": { Description: fmt.Sprintf("Determine if developers can create projects in the group. Valid values are: %s", utils.RenderValueListForDocs(projectCreationLevelValues)), Type: schema.TypeString, Optional: true, Computed: true, ValidateFunc: validation.StringInSlice(projectCreationLevelValues, true), }, "auto_devops_enabled": { Description: "Default to Auto DevOps pipeline for all projects within this group.", Type: schema.TypeBool, Optional: true, Computed: true, }, "emails_enabled": { Description: "Enable email notifications.", Type: schema.TypeBool, Optional: true, Computed: true, }, "mentions_disabled": { Description: "Disable the capability of a group from getting mentioned.", Type: schema.TypeBool, Optional: true, Computed: true, }, "subgroup_creation_level": { Description: fmt.Sprintf("Allowed to create subgroups. Valid values are: %s.", utils.RenderValueListForDocs(subGroupCreationLevelValues)), Type: schema.TypeString, Optional: true, Computed: true, ValidateFunc: validation.StringInSlice(subGroupCreationLevelValues, true), }, "require_two_factor_authentication": { Description: "Require all users in this group to setup Two-factor authentication.", Type: schema.TypeBool, Optional: true, Computed: true, }, "two_factor_grace_period": { Description: "Defaults to 48. Time before Two-factor authentication is enforced (in hours).", Type: schema.TypeInt, Optional: true, Computed: true, }, "parent_id": { Description: "Id of the parent group (creates a nested group).", Type: schema.TypeInt, Optional: true, Computed: true, }, "runners_token": { Description: "The group level registration token to use during runner setup.", Type: schema.TypeString, Computed: true, Sensitive: true, }, "prevent_forking_outside_group": { Description: "Defaults to false. When enabled, users can not fork projects from this group to external namespaces.", Type: schema.TypeBool, Optional: true, Computed: true, }, "membership_lock": { Description: "Users cannot be added to projects in this group.", Type: schema.TypeBool, Optional: true, Computed: true, }, "extra_shared_runners_minutes_limit": { Description: "Can be set by administrators only. Additional CI/CD minutes for this group.", Type: schema.TypeInt, Optional: true, Computed: true, }, "shared_runners_minutes_limit": { Description: "Can be set by administrators only. Maximum number of monthly CI/CD minutes for this group. Can be nil (default; inherit system default), 0 (unlimited), or > 0.", Type: schema.TypeInt, Optional: true, Computed: true, }, "ip_restriction_ranges": { Description: "A list of IP addresses or subnet masks to restrict group access. Will be concatenated together into a comma separated string. Only allowed on top level groups.", Type: schema.TypeList, Elem: &schema.Schema{Type: schema.TypeString}, Optional: true, }, "allowed_email_domains_list": { Description: "A list of email address domains to allow group access. Will be concatenated together into a comma separated string.", Type: schema.TypeList, Elem: &schema.Schema{Type: schema.TypeString}, Optional: true, Computed: true, }, "wiki_access_level": { Description: fmt.Sprintf("The group's wiki access level. Only available on Premium and Ultimate plans. Valid values are %s.", utils.RenderValueListForDocs(validWikiAccessLevels)), Type: schema.TypeString, Optional: true, Computed: true, ValidateFunc: validation.StringInSlice(validWikiAccessLevels, true), }, "shared_runners_setting": { Description: fmt.Sprintf("Enable or disable shared runners for a group’s subgroups and projects. Valid values are: %s.", utils.RenderValueListForDocs(validSharedRunnersSettings)), Type: schema.TypeString, Optional: true, Computed: true, ValidateFunc: validation.StringInSlice(validSharedRunnersSettings, false), }, "permanently_remove_on_delete": { Description: "Whether the group should be permanently removed during a `delete` operation. This only works with subgroups. Must be configured via an `apply` before the `destroy` is run.", Type: schema.TypeBool, Optional: true, Default: false, }, "push_rules": { Description: "Push rules for the group.", Type: schema.TypeList, MaxItems: 1, Optional: true, Computed: true, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "author_email_regex": { Description: "All commit author emails must match this regex, e.g. `@my-company.com$`.", Type: schema.TypeString, Optional: true, Computed: true, }, "branch_name_regex": { Description: "All branch names must match this regex, e.g. `(feature|hotfix)\\/*`.", Type: schema.TypeString, Optional: true, Computed: true, }, "commit_message_regex": { Description: "All commit messages must match this regex, e.g. `Fixed \\d+\\..*`.", Type: schema.TypeString, Optional: true, Computed: true, }, "commit_message_negative_regex": { Description: "No commit message is allowed to match this regex, for example `ssh\\:\\/\\/`.", Type: schema.TypeString, Optional: true, Computed: true, }, "file_name_regex": { Description: "Filenames matching the regular expression provided in this attribute are not allowed, for example, `(jar|exe)$`.", Type: schema.TypeString, Optional: true, Computed: true, }, "commit_committer_check": { Description: "Only commits pushed using verified emails are allowed.", Type: schema.TypeBool, Optional: true, Computed: true, }, "commit_committer_name_check": { Description: "Users can only push commits to this repository if the commit author name is consistent with their GitLab account name.", Type: schema.TypeBool, Optional: true, Computed: true, }, "deny_delete_tag": { Description: "Deny deleting a tag.", Type: schema.TypeBool, Optional: true, Computed: true, }, "member_check": { Description: "Allows only GitLab users to author commits.", Type: schema.TypeBool, Optional: true, Computed: true, }, "prevent_secrets": { Description: "GitLab will reject any files that are likely to contain secrets.", Type: schema.TypeBool, Optional: true, Computed: true, }, "reject_unsigned_commits": { Description: "Only commits signed through GPG are allowed.", Type: schema.TypeBool, Optional: true, Computed: true, }, "reject_non_dco_commits": { Description: "Reject commit when it’s not DCO certified.", Type: schema.TypeBool, Optional: true, Computed: true, }, "max_file_size": { Description: "Maximum file size (MB) allowed.", Type: schema.TypeInt, Optional: true, Computed: true, ValidateFunc: validation.IntAtLeast(0), }, }, }, }, }, avatarableSchema()), CustomizeDiff: avatarableDiff, } }) func resourceGitlabGroupCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*gitlab.Client) options := &gitlab.CreateGroupOptions{ Name: gitlab.Ptr(d.Get("name").(string)), } if v, ok := d.GetOk("path"); ok { options.Path = gitlab.Ptr(v.(string)) } if v, ok := d.GetOk("default_branch"); ok { options.DefaultBranch = gitlab.Ptr(v.(string)) } if v, ok := d.GetOk("description"); ok { options.Description = gitlab.Ptr(v.(string)) } if v, ok := d.GetOk("visibility_level"); ok { options.Visibility = stringToVisibilityLevel(v.(string)) } if v, ok := d.GetOk("share_with_group_lock"); ok { options.ShareWithGroupLock = gitlab.Ptr(v.(bool)) } // nolint:staticcheck // SA1019 ignore deprecated GetOkExists // lintignore: XR001 // TODO: replace with alternative for GetOkExists if v, ok := d.GetOkExists("lfs_enabled"); ok { options.LFSEnabled = gitlab.Ptr(v.(bool)) } // nolint:staticcheck // SA1019 ignore deprecated GetOkExists // lintignore: XR001 // TODO: replace with alternative for GetOkExists if v, ok := d.GetOkExists("request_access_enabled"); ok { options.RequestAccessEnabled = gitlab.Ptr(v.(bool)) } // nolint:staticcheck // SA1019 ignore deprecated GetOkExists // lintignore: XR001 // TODO: replace with alternative for GetOkExists if v, ok := d.GetOkExists("require_two_factor_authentication"); ok { options.RequireTwoFactorAuth = gitlab.Ptr(v.(bool)) } if v, ok := d.GetOk("two_factor_grace_period"); ok { options.TwoFactorGracePeriod = gitlab.Ptr(v.(int)) } if v, ok := d.GetOk("project_creation_level"); ok { options.ProjectCreationLevel = stringToProjectCreationLevel(v.(string)) } // nolint:staticcheck // SA1019 ignore deprecated GetOkExists // lintignore: XR001 // TODO: replace with alternative for GetOkExists if v, ok := d.GetOkExists("auto_devops_enabled"); ok { options.AutoDevopsEnabled = gitlab.Ptr(v.(bool)) } if v, ok := d.GetOk("subgroup_creation_level"); ok { options.SubGroupCreationLevel = stringToSubGroupCreationLevel(v.(string)) } // nolint:staticcheck // SA1019 ignore deprecated GetOkExists // lintignore: XR001 // TODO: replace with alternative for GetOkExists if v, ok := d.GetOkExists("emails_enabled"); ok { options.EmailsEnabled = gitlab.Ptr(v.(bool)) } // nolint:staticcheck // SA1019 ignore deprecated GetOkExists // lintignore: XR001 // TODO: replace with alternative for GetOkExists if v, ok := d.GetOkExists("mentions_disabled"); ok { options.MentionsDisabled = gitlab.Ptr(v.(bool)) } if v, ok := d.GetOk("parent_id"); ok { options.ParentID = gitlab.Ptr(v.(int)) } // nolint:staticcheck // SA1019 ignore deprecated GetOkExists // lintignore: XR001 // TODO: replace with alternative for GetOkExists if v, ok := d.GetOkExists("default_branch_protection"); ok { options.DefaultBranchProtection = gitlab.Ptr(v.(int)) } if v, ok := d.GetOk("default_branch_protection_defaults.0"); ok { defaults := v.(map[string]any) options.DefaultBranchProtectionDefaults = &gitlab.DefaultBranchProtectionDefaultsOptions{ AllowedToPush: gitlab.Ptr(convertAccessLevelNamesToValues(defaults["allowed_to_push"].([]any))), AllowForcePush: gitlab.Ptr(defaults["allow_force_push"].(bool)), AllowedToMerge: gitlab.Ptr(convertAccessLevelNamesToValues(defaults["allowed_to_merge"].([]any))), DeveloperCanInitialPush: gitlab.Ptr(defaults["developer_can_initial_push"].(bool)), } } // nolint:staticcheck // SA1019 ignore deprecated GetOkExists // lintignore: XR001 // TODO: replace with alternative for GetOkExists if v, ok := d.GetOkExists("membership_lock"); ok { options.MembershipLock = gitlab.Ptr(v.(bool)) } if v, ok := d.GetOk("extra_shared_runners_minutes_limit"); ok { options.ExtraSharedRunnersMinutesLimit = gitlab.Ptr(v.(int)) } if v, ok := d.GetOk("shared_runners_minutes_limit"); ok { options.SharedRunnersMinutesLimit = gitlab.Ptr(v.(int)) } avatar, err := handleAvatarOnCreate(d) if err != nil { return diag.FromErr(err) } if avatar != nil { options.Avatar = &gitlab.GroupAvatar{ Filename: avatar.Filename, Image: avatar.Image, } } if v, ok := d.GetOk("wiki_access_level"); ok { options.WikiAccessLevel = stringToAccessControlValue(v.(string)) } tflog.Debug(ctx, "[DEBUG] create gitlab group", map[string]any{ "name": *options.Name, }) group, _, err := client.Groups.CreateGroup(options, gitlab.WithContext(ctx)) if err != nil { return diag.FromErr(err) } // Wait for the Group to return properly before we update it // Groups are created asynchronously, so we want to ensure the create operation // completely finishes before we act on the group, or we can get an error. // see: https://gitlab.com/gitlab-org/terraform-provider-gitlab/-/issues/692 stateConf := &retry.StateChangeConf{ Pending: []string{"Creating"}, Target: []string{"Created"}, Refresh: func() (any, string, error) { out, _, err := client.Groups.GetGroup(group.ID, nil, gitlab.WithContext(ctx)) if err != nil { if api.Is404(err) { return out, "Creating", nil } tflog.Error(ctx, "[ERROR] Received error retrieving group", map[string]any{ "group_id": group.ID, "error": err, }) return out, "Error", err } return out, "Created", nil }, Timeout: d.Timeout(schema.TimeoutCreate), MinTimeout: 3 * time.Second, Delay: 5 * time.Second, } _, err = stateConf.WaitForStateContext(ctx) if err != nil { return diag.Errorf("error waiting for group (%s) to create: %s", d.Id(), err) } // Our group has been created, we can now update it. d.SetId(fmt.Sprintf("%d", group.ID)) if _, ok := d.GetOk("push_rules"); ok { err := editOrAddGroupPushRules(ctx, client, d.Id(), d) if err != nil { if api.Is404(err) { tflog.Error(ctx, "[ERROR] Failed to edit push rules for group", map[string]any{ "group_id": d.Id(), "error": err, }) return diag.Errorf("Group push rules are not supported in your version of GitLab") } return diag.Errorf("Failed to edit push rules for group %q: %s", d.Id(), err) } } var updateOptions gitlab.UpdateGroupOptions // nolint:staticcheck // SA1019 ignore deprecated GetOkExists // lintignore: XR001 // TODO: replace with alternative for GetOkExists if v, ok := d.GetOkExists("prevent_forking_outside_group"); ok { updateOptions.PreventForkingOutsideGroup = gitlab.Ptr(v.(bool)) } // IP Restriction can only be set on update. if v, ok := d.GetOk("ip_restriction_ranges"); ok { updateOptions.IPRestrictionRanges = stringListToCommaSeparatedString(v.([]any)) } // Email domains can only be set on update. if v, ok := d.GetOk("allowed_email_domains_list"); ok { updateOptions.AllowedEmailDomainsList = stringListToCommaSeparatedString(v.([]any)) } if v, ok := d.GetOk("shared_runners_setting"); ok { updateOptions.SharedRunnersSetting = stringToSharedRunnersSetting(v.(string)) } if (updateOptions != gitlab.UpdateGroupOptions{}) { if _, _, err = client.Groups.UpdateGroup(d.Id(), &updateOptions, gitlab.WithContext(ctx)); err != nil { return diag.Errorf("could not update group after creation %q: %s", d.Id(), err) } } return resourceGitlabGroupRead(ctx, d, meta) } func convertAccessLevelNamesToValues(names []any) []*gitlab.GroupAccessLevel { valuesList := []*gitlab.GroupAccessLevel{} for _, name := range names { nameStr := name.(string) groupAccessLevel := gitlab.GroupAccessLevel{ AccessLevel: gitlab.Ptr(api.AccessLevelNameToValue[nameStr]), } valuesList = append(valuesList, &groupAccessLevel) } return valuesList } func resourceGitlabGroupRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*gitlab.Client) tflog.Debug(ctx, "[DEBUG] read gitlab group", map[string]any{ "group_id": d.Id(), }) group, _, err := client.Groups.GetGroup( d.Id(), &gitlab.GetGroupOptions{WithProjects: gitlab.Ptr(false)}, gitlab.WithContext(ctx), ) if err != nil { if api.Is404(err) { tflog.Debug(ctx, "[DEBUG] gitlab group not found so removing", map[string]any{ "id": d.Id(), }) d.SetId("") return nil } return diag.FromErr(err) } if group.MarkedForDeletionOn != nil { tflog.Debug(ctx, "[DEBUG] gitlab group marked for deletion", map[string]any{ "id": d.Id(), }) d.SetId("") return nil } d.SetId(fmt.Sprintf("%d", group.ID)) d.Set("name", group.Name) d.Set("path", group.Path) d.Set("full_path", group.FullPath) d.Set("full_name", group.FullName) d.Set("web_url", group.WebURL) d.Set("default_branch", group.DefaultBranch) d.Set("description", group.Description) d.Set("lfs_enabled", group.LFSEnabled) d.Set("request_access_enabled", group.RequestAccessEnabled) d.Set("visibility_level", group.Visibility) d.Set("project_creation_level", group.ProjectCreationLevel) d.Set("subgroup_creation_level", group.SubGroupCreationLevel) d.Set("require_two_factor_authentication", group.RequireTwoFactorAuth) d.Set("two_factor_grace_period", group.TwoFactorGracePeriod) d.Set("auto_devops_enabled", group.AutoDevopsEnabled) d.Set("mentions_disabled", group.MentionsDisabled) d.Set("parent_id", group.ParentID) d.Set("runners_token", group.RunnersToken) d.Set("share_with_group_lock", group.ShareWithGroupLock) d.Set("prevent_forking_outside_group", group.PreventForkingOutsideGroup) d.Set("membership_lock", group.MembershipLock) d.Set("extra_shared_runners_minutes_limit", group.ExtraSharedRunnersMinutesLimit) d.Set("shared_runners_minutes_limit", group.SharedRunnersMinutesLimit) d.Set("avatar_url", group.AvatarURL) d.Set("wiki_access_level", group.WikiAccessLevel) d.Set("shared_runners_setting", group.SharedRunnersSetting) d.Set("emails_enabled", group.EmailsEnabled) // nolint:staticcheck // SA1019 ignore deprecated DefaultBranchProtection d.Set("default_branch_protection", group.DefaultBranchProtection) if group.DefaultBranchProtectionDefaults != nil { err = d.Set("default_branch_protection_defaults", []map[string]any{ { "allowed_to_push": convertAccessLevelValuesToNames(group.DefaultBranchProtectionDefaults.AllowedToPush), "allow_force_push": group.DefaultBranchProtectionDefaults.AllowForcePush, "allowed_to_merge": convertAccessLevelValuesToNames(group.DefaultBranchProtectionDefaults.AllowedToMerge), "developer_can_initial_push": group.DefaultBranchProtectionDefaults.DeveloperCanInitialPush, }, }) if err != nil { return diag.FromErr(err) } } // The value comes back from the API as a comma separated string, and stores in TF as a set. // We need to set the value only if it's "", otherwise the split gives up [""] which will result // in a non-empty plan. IPValue := []string{} if group.IPRestrictionRanges != "" { IPValue = strings.Split(group.IPRestrictionRanges, ",") } if err := d.Set("ip_restriction_ranges", IPValue); err != nil { tflog.Error(ctx, "Error setting ip_restriction_ranges.") return diag.FromErr(err) } // The value comes back from the API as a comma separated string, and stores in TF as a set. // We need to set the value only if it's "", otherwise the split gives up [""] which will result // in a non-empty plan. emailDomains := []string{} if group.AllowedEmailDomainsList != "" { emailDomains = strings.Split(group.AllowedEmailDomainsList, ",") } if err := d.Set("allowed_email_domains_list", emailDomains); err != nil { tflog.Error(ctx, "Error setting allowed_email_domains_list.") return diag.FromErr(err) } isEE, err := utils.IsRunningInEEContext(client) if err != nil { return diag.FromErr(err) } if isEE { tflog.Debug(ctx, "[DEBUG] read gitlab group push rules", map[string]any{"id": d.Id()}) pushRules, _, err := client.Groups.GetGroupPushRules(d.Id(), gitlab.WithContext(ctx)) if api.Is404(err) { tflog.Error(ctx, "[ERROR] Failed to get push rules for group", map[string]any{ "group_id": d.Id(), "error": err, }) } else if err != nil { return diag.Errorf("Failed to get push rules for group %q: %s", d.Id(), err) } pushRuleValues, err := flattenGroupPushRules(ctx, client, pushRules) if err != nil { return diag.FromErr(err) } if err := d.Set("push_rules", pushRuleValues); err != nil { return diag.FromErr(err) } } else { tflog.Debug(ctx, "[DEBUG] gitlab group push rule not read due to gitlab community edition") } return nil } func convertAccessLevelValuesToNames(values []*gitlab.GroupAccessLevel) []*string { namesList := []*string{} for _, value := range values { namesList = append(namesList, gitlab.Ptr(api.AccessLevelValueToName[*value.AccessLevel])) } return namesList } func resourceGitlabGroupUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*gitlab.Client) options := &gitlab.UpdateGroupOptions{} if d.HasChange("name") { options.Name = gitlab.Ptr(d.Get("name").(string)) } if d.HasChange("path") { options.Path = gitlab.Ptr(d.Get("path").(string)) } if d.HasChange("default_branch") { options.DefaultBranch = gitlab.Ptr(d.Get("default_branch").(string)) } if d.HasChange("description") { options.Description = gitlab.Ptr(d.Get("description").(string)) } if d.HasChange("lfs_enabled") { options.LFSEnabled = gitlab.Ptr(d.Get("lfs_enabled").(bool)) } if d.HasChange("request_access_enabled") { options.RequestAccessEnabled = gitlab.Ptr(d.Get("request_access_enabled").(bool)) } // Always set visibility ; workaround for // https://gitlab.com/gitlab-org/gitlab-foss/-/issues/38459 if v, ok := d.GetOk("visibility_level"); ok { options.Visibility = stringToVisibilityLevel(v.(string)) } if d.HasChange("project_creation_level") { options.ProjectCreationLevel = stringToProjectCreationLevel(d.Get("project_creation_level").(string)) } if d.HasChange("subgroup_creation_level") { options.SubGroupCreationLevel = stringToSubGroupCreationLevel(d.Get("subgroup_creation_level").(string)) } if d.HasChange("require_two_factor_authentication") { options.RequireTwoFactorAuth = gitlab.Ptr(d.Get("require_two_factor_authentication").(bool)) } if d.HasChange("two_factor_grace_period") { options.TwoFactorGracePeriod = gitlab.Ptr(d.Get("two_factor_grace_period").(int)) } if d.HasChange("auto_devops_enabled") { options.AutoDevopsEnabled = gitlab.Ptr(d.Get("auto_devops_enabled").(bool)) } if d.HasChange("emails_enabled") { options.EmailsEnabled = gitlab.Ptr(d.Get("emails_enabled").(bool)) } if d.HasChange("mentions_disabled") { options.MentionsDisabled = gitlab.Ptr(d.Get("mentions_disabled").(bool)) } if d.HasChange("share_with_group_lock") { options.ShareWithGroupLock = gitlab.Ptr(d.Get("share_with_group_lock").(bool)) } if d.HasChange("default_branch_protection") { // nolint:staticcheck // SA1019 ignore deprecated DefaultBranchProtection options.DefaultBranchProtection = gitlab.Ptr(d.Get("default_branch_protection").(int)) } if d.HasChange("default_branch_protection_defaults.0") { options.DefaultBranchProtectionDefaults = gitlab.Ptr(expandDefaultBranchProtectionDefaults(d)) } if d.HasChange("prevent_forking_outside_group") { options.PreventForkingOutsideGroup = gitlab.Ptr(d.Get("prevent_forking_outside_group").(bool)) } if d.HasChange("membership_lock") { options.MembershipLock = gitlab.Ptr(d.Get("membership_lock").(bool)) } if d.HasChange("extra_shared_runners_minutes_limit") { options.ExtraSharedRunnersMinutesLimit = gitlab.Ptr(d.Get("extra_shared_runners_minutes_limit").(int)) } if d.HasChange("shared_runners_minutes_limit") { options.SharedRunnersMinutesLimit = gitlab.Ptr(d.Get("shared_runners_minutes_limit").(int)) } if d.HasChange("ip_restriction_ranges") { options.IPRestrictionRanges = stringListToCommaSeparatedString(d.Get("ip_restriction_ranges").([]any)) } if d.HasChange("allowed_email_domains_list") { options.AllowedEmailDomainsList = stringListToCommaSeparatedString(d.Get("allowed_email_domains_list").([]any)) } avatar, err := handleAvatarOnUpdate(d) if err != nil { return diag.FromErr(err) } if avatar != nil { options.Avatar = &gitlab.GroupAvatar{ Filename: avatar.Filename, Image: avatar.Image, } } if d.HasChange("wiki_access_level") { options.WikiAccessLevel = stringToAccessControlValue(d.Get("wiki_access_level").(string)) } if d.HasChange("shared_runners_setting") { options.SharedRunnersSetting = stringToSharedRunnersSetting(d.Get("shared_runners_setting").(string)) } tflog.Debug(ctx, "update gitlab group", map[string]any{ "group_id": d.Id(), "options": fmt.Sprintf("%+v", options), }) _, _, err = client.Groups.UpdateGroup(d.Id(), options, gitlab.WithContext(ctx)) if err != nil { return diag.FromErr(err) } if d.HasChange("parent_id") { err = transferSubGroup(ctx, d, client) if err != nil { return diag.FromErr(err) } } if d.HasChange("push_rules") { err := editOrAddGroupPushRules(ctx, client, d.Id(), d) if err != nil { if api.Is404(err) { tflog.Error(ctx, "[ERROR] Failed to edit push rules for group", map[string]any{ "group_id": d.Id(), "error": err, }) return diag.Errorf("Group push rules are not supported in your version of GitLab") } return diag.Errorf("Failed to edit push rules for group %q: %s", d.Id(), err) } } return resourceGitlabGroupRead(ctx, d, meta) } func transferSubGroup(ctx context.Context, d *schema.ResourceData, client *gitlab.Client) error { o, n := d.GetChange("parent_id") parentId, ok := n.(int) if !ok { return fmt.Errorf("error converting parent_id %v into an int", n) } opt := &gitlab.TransferSubGroupOptions{} if parentId != 0 { tflog.Debug(ctx, "transfer gitlab group", map[string]any{ "group_id": d.Id(), "old_group": o, "new_group": parentId, }) opt.GroupID = gitlab.Ptr(parentId) } else { tflog.Debug(ctx, "turn gitlab group into a new top-level group", map[string]any{ "group_id": d.Id(), "old_group": o, }) } _, _, err := client.Groups.TransferSubGroup(d.Id(), opt, gitlab.WithContext(ctx)) if err != nil { return fmt.Errorf("error transfering group %s to new parent group %v: %s", d.Id(), parentId, err) } return nil } func resourceGitlabGroupDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*gitlab.Client) tflog.Debug(ctx, "delete gitlab group", map[string]any{ "id": d.Id(), }) _, err := client.Groups.DeleteGroup(d.Id(), nil, gitlab.WithContext(ctx)) if err != nil && !strings.Contains(err.Error(), "Group has been already marked for deletion") { return diag.Errorf("error deleting group %s: %s", d.Id(), err) } // Wait for the group to be deleted. // Deleting a group in gitlab is async. stateConf := &retry.StateChangeConf{ Pending: []string{"Deleting"}, Target: []string{"Deleted"}, Refresh: func() (any, string, error) { out, response, err := client.Groups.GetGroup(d.Id(), nil, gitlab.WithContext(ctx)) if err != nil { if response != nil && response.StatusCode == 404 { return out, "Deleted", nil } tflog.Error(ctx, "Received error", map[string]any{ "error": err, }) return out, "Error", err } if out.MarkedForDeletionOn != nil { // Represents a Gitlab EE soft-delete return out, "Deleted", nil } return out, "Deleting", nil }, Timeout: 10 * time.Minute, MinTimeout: 3 * time.Second, Delay: 5 * time.Second, } _, err = stateConf.WaitForStateContext(ctx) if err != nil { return diag.Errorf("error waiting for group (%s) to become deleted: %s", d.Id(), err) } // If permanent deletion is selected, issue a second "permanently delete" API call if d.Get("permanently_remove_on_delete").(bool) && d.Get("full_path").(string) != "" { tflog.Debug(ctx, "Attempting to permanently delete the group", map[string]any{ "group": d.Get("full_path").(string), }) opts := &gitlab.DeleteGroupOptions{} opts.PermanentlyRemove = gitlab.Ptr(d.Get("permanently_remove_on_delete").(bool)) opts.FullPath = gitlab.Ptr(d.Get("full_path").(string)) _, err = client.Groups.DeleteGroup(d.Id(), opts, gitlab.WithContext(ctx)) if err != nil { return diag.Errorf("group (%s) was marked for deletion, but permanent deletion of the group failed.", d.Id()) } // Group Deletion happens in the background, so we should wait until we get a 404 when reading the group: // Wait for the group to be deleted. stateConf := &retry.StateChangeConf{ Pending: []string{"Deleting"}, Target: []string{"Deleted"}, Refresh: func() (any, string, error) { out, response, err := client.Groups.GetGroup(d.Id(), nil, gitlab.WithContext(ctx)) if err != nil { if response != nil && response.StatusCode == 404 { return out, "Deleted", nil } tflog.Error(ctx, "Received error", map[string]any{ "error": err, }) return out, "Error", err } return out, "Deleting", nil }, Timeout: 10 * time.Minute, MinTimeout: 3 * time.Second, Delay: 5 * time.Second, } _, err = stateConf.WaitForStateContext(ctx) if err != nil { return diag.Errorf("error waiting for group (%s) to become permanently deleted: %s", d.Id(), err) } } return nil } func editOrAddGroupPushRules(ctx context.Context, client *gitlab.Client, groupID string, d *schema.ResourceData) error { tflog.Debug(ctx, "[DEBUG] Editing push rules for group", map[string]any{ "group_id": groupID, }) pushRules, _, err := client.Groups.GetGroupPushRules(d.Id(), gitlab.WithContext(ctx)) // NOTE: push rules id `0` indicates that there haven't been any push rules set. if err != nil || pushRules.ID == 0 { addOptions, err := expandAddGroupPushRuleOptions(ctx, client, d) if err != nil { return err } if (gitlab.AddGroupPushRuleOptions{}) != addOptions { tflog.Debug(ctx, "[DEBUG] Creating new push rules for group", map[string]any{ "group_id": groupID, }) _, _, err = client.Groups.AddGroupPushRule(groupID, &addOptions, gitlab.WithContext(ctx)) if err != nil { return err } } else { tflog.Debug(ctx, "[DEBUG] Don't create new push rules for defaults for group", map[string]any{ "group_id": groupID, }) } return nil } editOptions, err := expandEditGroupPushRuleOptions(ctx, client, d) if err != nil { return err } if (gitlab.EditGroupPushRuleOptions{}) != editOptions { tflog.Debug(ctx, "[DEBUG] Editing existing push rules for group", map[string]any{ "group_id": groupID, }) _, _, err = client.Groups.EditGroupPushRule(groupID, &editOptions, gitlab.WithContext(ctx)) if err != nil { return err } } else { tflog.Debug(ctx, "[DEBUG] Don't edit existing push rules for defaults for group", map[string]any{ "group_id": groupID, }) } return nil } func expandDefaultBranchProtectionDefaults(d *schema.ResourceData) gitlab.DefaultBranchProtectionDefaultsOptions { options := gitlab.DefaultBranchProtectionDefaultsOptions{} options.AllowedToPush = gitlab.Ptr(convertAccessLevelNamesToValues(d.Get("default_branch_protection_defaults.0.allowed_to_push").([]any))) options.AllowForcePush = gitlab.Ptr(d.Get("default_branch_protection_defaults.0.allow_force_push").(bool)) options.AllowedToMerge = gitlab.Ptr(convertAccessLevelNamesToValues(d.Get("default_branch_protection_defaults.0.allowed_to_merge").([]any))) options.DeveloperCanInitialPush = gitlab.Ptr(d.Get("default_branch_protection_defaults.0.developer_can_initial_push").(bool)) return options } func expandEditGroupPushRuleOptions(ctx context.Context, client *gitlab.Client, d *schema.ResourceData) (gitlab.EditGroupPushRuleOptions, error) { options := gitlab.EditGroupPushRuleOptions{} if d.HasChange("push_rules.0.commit_committer_check") { options.CommitCommitterCheck = gitlab.Ptr(d.Get("push_rules.0.commit_committer_check").(bool)) } if d.HasChange("push_rules.0.reject_unsigned_commits") { options.RejectUnsignedCommits = gitlab.Ptr(d.Get("push_rules.0.reject_unsigned_commits").(bool)) } if d.HasChange("push_rules.0.reject_non_dco_commits") { options.RejectNonDCOCommits = gitlab.Ptr(d.Get("push_rules.0.reject_non_dco_commits").(bool)) } if d.HasChange("push_rules.0.author_email_regex") { options.AuthorEmailRegex = gitlab.Ptr(d.Get("push_rules.0.author_email_regex").(string)) } if d.HasChange("push_rules.0.branch_name_regex") { options.BranchNameRegex = gitlab.Ptr(d.Get("push_rules.0.branch_name_regex").(string)) } if d.HasChange("push_rules.0.commit_message_regex") { options.CommitMessageRegex = gitlab.Ptr(d.Get("push_rules.0.commit_message_regex").(string)) } if d.HasChange("push_rules.0.commit_message_negative_regex") { options.CommitMessageNegativeRegex = gitlab.Ptr(d.Get("push_rules.0.commit_message_negative_regex").(string)) } if d.HasChange("push_rules.0.file_name_regex") { options.FileNameRegex = gitlab.Ptr(d.Get("push_rules.0.file_name_regex").(string)) } if d.HasChange("push_rules.0.commit_committer_name_check") { options.CommitCommitterNameCheck = gitlab.Ptr(d.Get("push_rules.0.commit_committer_name_check").(bool)) } if d.HasChange("push_rules.0.deny_delete_tag") { options.DenyDeleteTag = gitlab.Ptr(d.Get("push_rules.0.deny_delete_tag").(bool)) } if d.HasChange("push_rules.0.member_check") { options.MemberCheck = gitlab.Ptr(d.Get("push_rules.0.member_check").(bool)) } if d.HasChange("push_rules.0.prevent_secrets") { options.PreventSecrets = gitlab.Ptr(d.Get("push_rules.0.prevent_secrets").(bool)) } if d.HasChange("push_rules.0.max_file_size") { options.MaxFileSize = gitlab.Ptr(d.Get("push_rules.0.max_file_size").(int)) } return options, nil } func expandAddGroupPushRuleOptions(ctx context.Context, client *gitlab.Client, d *schema.ResourceData) (gitlab.AddGroupPushRuleOptions, error) { options := gitlab.AddGroupPushRuleOptions{} if v, ok := d.GetOk("push_rules.0.commit_committer_check"); ok { options.CommitCommitterCheck = gitlab.Ptr(v.(bool)) } if v, ok := d.GetOk("push_rules.0.reject_unsigned_commits"); ok { options.RejectUnsignedCommits = gitlab.Ptr(v.(bool)) } if v, ok := d.GetOk("push_rules.0.reject_non_dco_commits"); ok { options.RejectNonDCOCommits = gitlab.Ptr(v.(bool)) } if v, ok := d.GetOk("push_rules.0.author_email_regex"); ok { options.AuthorEmailRegex = gitlab.Ptr(v.(string)) } if v, ok := d.GetOk("push_rules.0.branch_name_regex"); ok { options.BranchNameRegex = gitlab.Ptr(v.(string)) } if v, ok := d.GetOk("push_rules.0.commit_message_regex"); ok { options.CommitMessageRegex = gitlab.Ptr(v.(string)) } if v, ok := d.GetOk("push_rules.0.commit_message_negative_regex"); ok { options.CommitMessageNegativeRegex = gitlab.Ptr(v.(string)) } if v, ok := d.GetOk("push_rules.0.file_name_regex"); ok { options.FileNameRegex = gitlab.Ptr(v.(string)) } if v, ok := d.GetOk("push_rules.0.commit_committer_name_check"); ok { options.CommitCommitterNameCheck = gitlab.Ptr(v.(bool)) } if v, ok := d.GetOk("push_rules.0.deny_delete_tag"); ok { options.DenyDeleteTag = gitlab.Ptr(v.(bool)) } if v, ok := d.GetOk("push_rules.0.member_check"); ok { options.MemberCheck = gitlab.Ptr(v.(bool)) } if v, ok := d.GetOk("push_rules.0.prevent_secrets"); ok { options.PreventSecrets = gitlab.Ptr(v.(bool)) } if v, ok := d.GetOk("push_rules.0.max_file_size"); ok { options.MaxFileSize = gitlab.Ptr(v.(int)) } return options, nil } func flattenGroupPushRules(ctx context.Context, client *gitlab.Client, pushRules *gitlab.GroupPushRules) (values []map[string]any, err error) { if pushRules == nil { return []map[string]any{}, nil } values = []map[string]any{ { "author_email_regex": pushRules.AuthorEmailRegex, "branch_name_regex": pushRules.BranchNameRegex, "commit_message_regex": pushRules.CommitMessageRegex, "commit_message_negative_regex": pushRules.CommitMessageNegativeRegex, "file_name_regex": pushRules.FileNameRegex, "commit_committer_check": pushRules.CommitCommitterCheck, "commit_committer_name_check": pushRules.CommitCommitterNameCheck, "deny_delete_tag": pushRules.DenyDeleteTag, "member_check": pushRules.MemberCheck, "prevent_secrets": pushRules.PreventSecrets, "reject_unsigned_commits": pushRules.RejectUnsignedCommits, "reject_non_dco_commits": pushRules.RejectNonDCOCommits, "max_file_size": pushRules.MaxFileSize, }, } return values, nil }