diff --git a/Readme.md b/Readme.md index 12de65e..8c04b6c 100644 --- a/Readme.md +++ b/Readme.md @@ -131,6 +131,27 @@ moved { } ``` +## Nested subgroups + +A group can be up to three levels deep: a top-level group, a subgroup, and a group in that subgroup. +For a group in a subgroup, set `parent` to the key of the subgroup, `/`: + +```yaml +groups: + - name: infra + - name: terraform + parent: infra + - name: terraform-aws-modules + parent: infra/terraform # creates infra/terraform/terraform-aws-modules +``` + +`parent` uses the subgroup's `name`, not its `path`. Keep `name` and `path` the same, so `parent` is also the full path of the parent group, and a project can use it as `namespace`. + +Groups whose parent is a top-level group stay in `gitlab_group.subgroups`. Groups whose parent is a subgroup go to `gitlab_group.nested_subgroups`. Thus the addresses of current groups do not change. +A `parent` deeper than a subgroup (`a/b/c`) fails validation. + +See [examples/terraform/groups/infra.yaml](examples/terraform/groups/infra.yaml) and [examples/terraform/projects/infra_projects.yaml](examples/terraform/projects/infra_projects.yaml). + ## Outputs The module provides the following outputs: diff --git a/examples/terraform/groups/infra.yaml b/examples/terraform/groups/infra.yaml new file mode 100644 index 0000000..c5e5aa6 --- /dev/null +++ b/examples/terraform/groups/infra.yaml @@ -0,0 +1,28 @@ +groups: + - name: infra + create: true + settings: + visibility: private + description: "Infrastructure top-level group" + + - name: terraform + create: true + parent: infra + settings: + path: terraform + visibility: internal + description: "Terraform modules mirrored from upstream" + share_groups: + - share_group_id: "roles/project-sre-dev" + group_access: maintainer + + # A group under a subgroup. parent is the key of the subgroup, "/", + # with the subgroup's name, not its path. Keep name and path the same to make + # parent equal to the full path of the parent group. + - name: terraform-aws-modules + create: true + parent: infra/terraform + settings: + path: terraform-aws-modules + visibility: internal + description: "Mirrors of github.com/terraform-aws-modules" diff --git a/examples/terraform/projects/infra_projects.yaml b/examples/terraform/projects/infra_projects.yaml new file mode 100644 index 0000000..7332a92 --- /dev/null +++ b/examples/terraform/projects/infra_projects.yaml @@ -0,0 +1,8 @@ +projects: + # A project in a nested subgroup: namespace is the full path of the group. + - name: terraform-aws-vpc + create: true + namespace: "infra/terraform/terraform-aws-modules" + visibility: internal + description: "Mirror of github.com/terraform-aws-modules/terraform-aws-vpc" + settings: {} diff --git a/main.tf b/main.tf index 154fc1f..22ffdae 100644 --- a/main.tf +++ b/main.tf @@ -63,12 +63,12 @@ resource "gitlab_group" "parent_groups" { } } -# Create Subgroups +# Create Subgroups (parent is a top-level group) resource "gitlab_group" "subgroups" { for_each = { for group in var.gitlab_groups : "${group.parent}/${group.name}" => group - if contains(keys(group), "parent") + if contains(keys(group), "parent") && !strcontains(lookup(group, "parent", ""), "/") } name = each.value.name @@ -130,6 +130,84 @@ resource "gitlab_group" "subgroups" { } } +# Create Nested Subgroups (parent is a subgroup, e.g. parent = "infra/terraform"). +# parent must match the subgroup key "/", which uses the subgroup's +# name, not its path. +resource "gitlab_group" "nested_subgroups" { + for_each = { + for group in var.gitlab_groups : + "${group.parent}/${group.name}" => group + if length(split("/", lookup(group, "parent", ""))) == 2 + } + + name = each.value.name + path = lookup(each.value.settings, "path", each.value.name) + description = lookup(each.value.settings, "description", null) + visibility_level = lookup(each.value.settings, "visibility", "private") + + parent_id = gitlab_group.subgroups[each.value.parent].id + + # Group settings + auto_devops_enabled = lookup(each.value.settings, "auto_devops_enabled", false) + lfs_enabled = lookup(each.value.settings, "lfs_enabled", true) + mentions_disabled = lookup(each.value.settings, "mentions_disabled", false) + project_creation_level = lookup(each.value.settings, "project_creation_level", "maintainer") + request_access_enabled = lookup(each.value.settings, "request_access_enabled", false) + require_two_factor_authentication = lookup(each.value.settings, "require_two_factor_authentication", false) + share_with_group_lock = lookup(each.value.settings, "share_with_group_lock", false) + subgroup_creation_level = lookup(each.value.settings, "subgroup_creation_level", "owner") + two_factor_grace_period = lookup(each.value.settings, "two_factor_grace_period", 48) + avatar = lookup(each.value.settings, "avatar", null) + avatar_hash = lookup(each.value.settings, "avatar_hash", null) + emails_enabled = lookup(each.value.settings, "emails_enabled", null) + extra_shared_runners_minutes_limit = lookup(each.value.settings, "extra_shared_runners_minutes_limit", null) + ip_restriction_ranges = lookup(each.value.settings, "ip_restriction_ranges", null) + membership_lock = lookup(each.value.settings, "membership_lock", null) + prevent_forking_outside_group = lookup(each.value.settings, "prevent_forking_outside_group", null) + shared_runners_minutes_limit = lookup(each.value.settings, "shared_runners_minutes_limit", null) + shared_runners_setting = lookup(each.value.settings, "shared_runners_setting", null) + wiki_access_level = lookup(each.value.settings, "wiki_access_level", null) + + # Replace deprecated default_branch_protection with new block + default_branch_protection_defaults { + allow_force_push = lookup(each.value.settings, "allow_force_push", false) + allowed_to_merge = lookup(each.value.settings, "allowed_to_merge", ["maintainer"]) + allowed_to_push = lookup(each.value.settings, "allowed_to_push", ["maintainer"]) + developer_can_initial_push = lookup(each.value.settings, "developer_can_initial_push", false) + } + + dynamic "push_rules" { + for_each = try(each.value.settings.push_rules, []) + iterator = rule + content { + author_email_regex = try(rule.value.author_email_regex, null) + branch_name_regex = try(rule.value.branch_name_regex, null) + commit_committer_check = try(rule.value.commit_committer_check, null) + commit_message_negative_regex = try(rule.value.commit_message_negative_regex, null) + commit_message_regex = try(rule.value.commit_message_regex, null) + deny_delete_tag = try(rule.value.deny_delete_tag, null) + file_name_regex = try(rule.value.file_name_regex, null) + max_file_size = try(rule.value.max_file_size, null) + member_check = try(rule.value.member_check, null) + prevent_secrets = try(rule.value.prevent_secrets, null) + reject_unsigned_commits = try(rule.value.reject_unsigned_commits, null) + } + } + lifecycle { + create_before_destroy = true + } +} + +locals { + # IDs of every group this module manages, keyed the same way as the resources: + # "" for top-level groups, "/" for subgroups and nested subgroups. + group_ids = merge( + { for key, group in gitlab_group.parent_groups : key => group.id }, + { for key, group in gitlab_group.subgroups : key => group.id }, + { for key, group in gitlab_group.nested_subgroups : key => group.id }, + ) +} + # Create GitLab Group Access Tokens resource "gitlab_group_access_token" "this" { for_each = merge([ @@ -146,7 +224,7 @@ resource "gitlab_group_access_token" "this" { } ]...) - group = each.value.parent == null ? gitlab_group.parent_groups[each.value.group_name].id : gitlab_group.subgroups[each.value.group_name].id + group = local.group_ids[each.value.group_name] name = each.value.token.name scopes = each.value.token.scopes @@ -177,7 +255,7 @@ resource "gitlab_group_variable" "access_token_this" { } ]...) - group = each.value.parent == null ? gitlab_group.parent_groups[each.value.group_name].id : gitlab_group.subgroups[each.value.group_name].id + group = local.group_ids[each.value.group_name] key = each.value.variable_name value = sensitive(gitlab_group_access_token.this[each.key].token) protected = true @@ -202,7 +280,7 @@ resource "gitlab_group_badge" "this" { } ]...) - group = each.value.parent == null ? gitlab_group.parent_groups[each.value.group_name].id : gitlab_group.subgroups[each.value.group_name].id + group = local.group_ids[each.value.group_name] link_url = each.value.badge.link_url image_url = each.value.badge.image_url name = each.value.badge.name @@ -224,7 +302,7 @@ resource "gitlab_group_custom_attribute" "this" { } ]...) - group = each.value.parent == null ? gitlab_group.parent_groups[each.value.group_name].id : gitlab_group.subgroups[each.value.group_name].id + group = local.group_ids[each.value.group_name] key = each.value.attribute.key value = each.value.attribute.value } @@ -245,7 +323,7 @@ resource "gitlab_group_label" "this" { } ]...) - group = each.value.parent == null ? gitlab_group.parent_groups[each.value.group_name].id : gitlab_group.subgroups[each.value.group_name].id + group = local.group_ids[each.value.group_name] name = each.value.label.name description = lookup(each.value.label, "description", null) color = each.value.label.color @@ -268,7 +346,7 @@ resource "gitlab_group_epic_board" "this" { ]...) name = each.value.board.name - group = each.value.parent == null ? gitlab_group.parent_groups[each.value.group_name].id : gitlab_group.subgroups[each.value.group_name].id + group = local.group_ids[each.value.group_name] dynamic "lists" { for_each = length(each.value.board.lists) > 0 ? toset(each.value.board.lists) : [] @@ -295,7 +373,7 @@ resource "gitlab_group_hook" "this" { } ]...) - group = each.value.parent == null ? gitlab_group.parent_groups[each.value.group_name].id : gitlab_group.subgroups[each.value.group_name].id + group = local.group_ids[each.value.group_name] url = each.value.hook.url confidential_issues_events = lookup(each.value.hook, "confidential_issues_events", false) confidential_note_events = lookup(each.value.hook, "confidential_note_events", false) @@ -333,7 +411,7 @@ resource "gitlab_group_issue_board" "this" { ]...) name = each.value.board.name - group = each.value.parent == null ? gitlab_group.parent_groups[each.value.group_name].id : gitlab_group.subgroups[each.value.group_name].id + group = local.group_ids[each.value.group_name] labels = each.value.board.labels dynamic "lists" { for_each = length(each.value.board.lists) > 0 ? toset(each.value.board.lists) : [] @@ -373,7 +451,7 @@ resource "gitlab_group_membership" "this" { } ]...) - group_id = each.value.parent == null ? gitlab_group.parent_groups[each.value.group_name].id : gitlab_group.subgroups[each.value.group_name].id + group_id = local.group_ids[each.value.group_name] access_level = each.value.member.access_level user_id = data.gitlab_user.membership_user[each.value.member.user_id].id expires_at = lookup(each.value.member, "expires_at", null) @@ -398,7 +476,7 @@ resource "gitlab_group_protected_environment" "this" { } ]...) - group = each.value.parent == null ? gitlab_group.parent_groups[each.value.group_name].id : gitlab_group.subgroups[each.value.group_name].id + group = local.group_ids[each.value.group_name] deploy_access_levels = each.value.env.deploy_access_levels environment = each.value.env.environment approval_rules = each.value.env.approval_rules @@ -421,7 +499,7 @@ resource "gitlab_group_saml_link" "this" { } ]...) - group = each.value.parent == null ? gitlab_group.parent_groups[each.value.group_name].id : gitlab_group.subgroups[each.value.group_name].id + group = local.group_ids[each.value.group_name] access_level = each.value.saml_link.access_level saml_group_name = each.value.saml_link.saml_group_name } @@ -486,7 +564,7 @@ locals { resource "gitlab_group_variable" "this_sensitive" { for_each = local.group_variables_sensitive - group = each.value.parent == null ? gitlab_group.parent_groups[each.value.owner].id : gitlab_group.subgroups[each.value.owner].id + group = local.group_ids[each.value.owner] key = each.value.variable.key value = sensitive(each.value.variable.value) protected = lookup(each.value.variable, "protected", false) @@ -501,7 +579,7 @@ resource "gitlab_group_variable" "this_sensitive" { resource "gitlab_group_variable" "this" { for_each = local.group_variables - group = each.value.parent == null ? gitlab_group.parent_groups[each.value.owner].id : gitlab_group.subgroups[each.value.owner].id + group = local.group_ids[each.value.owner] key = each.value.variable.key value = each.value.variable.value protected = lookup(each.value.variable, "protected", false) @@ -598,6 +676,7 @@ locals { managed_groups_by_path = merge( { for name, group in gitlab_group.parent_groups : name => [{ group_id = group.id }] }, { for path, group in gitlab_group.subgroups : path => [{ group_id = group.id }] }, + { for path, group in gitlab_group.nested_subgroups : path => [{ group_id = group.id }] }, ) # Group by name for groups, allowing for duplicates (keeps the [0] indexing used @@ -640,8 +719,7 @@ resource "gitlab_project" "this" { namespace_id = try( local.exists_groups[each.value.namespace][0].group_id, - gitlab_group.subgroups[each.value.namespace].id, - gitlab_group.parent_groups[each.value.namespace].id, + local.group_ids[each.value.namespace], null ) @@ -1685,7 +1763,7 @@ resource "gitlab_group_deploy_token" "this" { : "group-${group.name}-${token.name}" # Fallback to group name only if no parent ) => { deploy_token = token - entity_id = contains(keys(group), "parent") ? gitlab_group.subgroups["${group.parent}/${group.name}"].id : gitlab_group.parent_groups[group.name].id + entity_id = local.group_ids[contains(keys(group), "parent") ? "${group.parent}/${group.name}" : group.name] } } ]...) @@ -1704,16 +1782,8 @@ resource "gitlab_group_share_group" "this" { group.parent != null ? "${group.parent}/${group.group_id}-${group.share_group_id}" : "${group.group_id}-${group.share_group_id}" => group } - group_id = contains(keys(gitlab_group.parent_groups), each.value.group_id) ? gitlab_group.parent_groups[each.value.group_id].id : ( - contains(keys(gitlab_group.subgroups), each.value.group_id) - ? gitlab_group.subgroups[each.value.group_id].id - : gitlab_group.subgroups["${each.value.parent}/${each.value.group_id}"].id - ) - share_group_id = contains(keys(gitlab_group.parent_groups), each.value.share_group_id) ? gitlab_group.parent_groups[each.value.share_group_id].id : ( - contains(keys(gitlab_group.subgroups), each.value.share_group_id) - ? gitlab_group.subgroups[each.value.share_group_id].id - : gitlab_group.subgroups["${each.value.parent}/${each.value.share_group_id}"].id - ) - group_access = each.value.group_access - expires_at = lookup(each.value, "expires_at", null) + group_id = try(local.group_ids[each.value.group_id], local.group_ids["${each.value.parent}/${each.value.group_id}"]) + share_group_id = try(local.group_ids[each.value.share_group_id], local.group_ids["${each.value.parent}/${each.value.share_group_id}"]) + group_access = each.value.group_access + expires_at = lookup(each.value, "expires_at", null) } diff --git a/outputs.tf b/outputs.tf index fc62c51..55ebd99 100644 --- a/outputs.tf +++ b/outputs.tf @@ -11,6 +11,11 @@ output "subgroups_group_ids" { value = [for group in gitlab_group.subgroups : group.id] } +output "nested_subgroups_group_ids" { + description = "List of created nested GitLab subgroup IDs." + value = [for group in gitlab_group.nested_subgroups : group.id] +} + output "project_ids" { description = "List of created GitLab project IDs." value = [for project in gitlab_project.this : project.id] diff --git a/tests/nested_subgroups.tftest.hcl b/tests/nested_subgroups.tftest.hcl new file mode 100644 index 0000000..6017457 --- /dev/null +++ b/tests/nested_subgroups.tftest.hcl @@ -0,0 +1,126 @@ +# Unit tests for groups nested under a subgroup. They use a mock provider and do not call GitLab. + +mock_provider "gitlab" { + # The example projects use arguments that take a project ID as a number. A random mock ID is not a number. + mock_resource "gitlab_project" { + defaults = { id = "1" } + } + mock_data "gitlab_project" { + defaults = { id = "1" } + } +} + +variables { + gitlab_groups = [ + { + name = "infra" + settings = {} + }, + { + name = "terraform" + parent = "infra" + settings = {} + }, + { + name = "terraform-aws-modules" + parent = "infra/terraform" + settings = { + share_groups = [ + { share_group_id = "infra/terraform", group_access = "maintainer" }, + ] + } + }, + ] + gitlab_projects = [ + { + name = "terraform-aws-vpc" + namespace = "infra/terraform/terraform-aws-modules" + settings = {} + }, + ] +} + +run "subgroup_of_a_top_level_group_stays_in_subgroups" { + command = plan + + assert { + condition = keys(gitlab_group.subgroups) == ["infra/terraform"] + error_message = "A group whose parent is a top-level group must stay in gitlab_group.subgroups, so its address does not change." + } +} + +run "subgroup_of_a_subgroup_goes_to_nested_subgroups" { + command = plan + + assert { + condition = keys(gitlab_group.nested_subgroups) == ["infra/terraform/terraform-aws-modules"] + error_message = "A group whose parent is a subgroup must be created in gitlab_group.nested_subgroups." + } +} + +run "nested_subgroup_works_as_share_target_and_project_namespace" { + command = plan + + assert { + condition = contains(keys(gitlab_group_share_group.this), "infra/terraform/terraform-aws-modules-infra/terraform") + error_message = "A nested subgroup must resolve in share_groups." + } + + assert { + condition = contains(keys(gitlab_project.this), "infra/terraform/terraform-aws-modules/terraform-aws-vpc") + error_message = "A project must be able to live in a nested subgroup." + } +} + +run "parent_deeper_than_a_subgroup_is_rejected" { + command = plan + + variables { + gitlab_groups = [ + { + name = "too-deep" + parent = "a/b/c" + settings = {} + }, + ] + gitlab_projects = [] + } + + expect_failures = [var.gitlab_groups] +} + +run "example_creates_nested_subgroup_and_its_project" { + command = plan + + variables { + gitlab_groups = concat( + yamldecode(file("examples/terraform/groups/alpha.yaml")).groups, + yamldecode(file("examples/terraform/groups/infra.yaml")).groups, + yamldecode(file("examples/terraform/groups/roles.yaml")).groups, + ) + gitlab_projects = concat( + yamldecode(file("examples/terraform/projects/alpha_projects.yaml")).projects, + yamldecode(file("examples/terraform/projects/infra_projects.yaml")).projects, + ) + } + + assert { + condition = contains(keys(gitlab_group.nested_subgroups), "infra/terraform/terraform-aws-modules") + error_message = "The example must create the nested subgroup infra/terraform/terraform-aws-modules." + } + + assert { + condition = gitlab_group.nested_subgroups["infra/terraform/terraform-aws-modules"].visibility_level == "internal" + error_message = "The nested subgroup must get the settings of its group entry." + } + + assert { + condition = contains(keys(gitlab_project.this), "infra/terraform/terraform-aws-modules/terraform-aws-vpc") + error_message = "The example must create a project in the nested subgroup." + } + + assert { + condition = contains(keys(gitlab_group_share_group.this), "infra/terraform-roles/project-sre-dev") + error_message = "The example must share infra/terraform with roles/project-sre-dev." + } +} diff --git a/variables.tf b/variables.tf index 18f0dec..75e2d9b 100644 --- a/variables.tf +++ b/variables.tf @@ -13,6 +13,13 @@ variable "gitlab_groups" { ]) error_message = "Each group variable must have a unique key and environment_scope pair in its group." } + + validation { + condition = alltrue([ + for group in var.gitlab_groups : length(split("/", lookup(group, "parent", ""))) <= 2 + ]) + error_message = "A group's parent can be a top-level group (\"infra\") or a subgroup (\"infra/terraform\"), not deeper." + } } variable "gitlab_projects" {