From e18d9ac2365d951e9a874c05bac70e5637bbccf6 Mon Sep 17 00:00:00 2001 From: wiseelf Date: Tue, 7 Jul 2026 11:59:15 +1000 Subject: [PATCH 1/6] feat!: Upgrade gitlab provider to 19.x BREAKING CHANGE: renames gitlab_project_mirror, gitlab_integration_* resources, splits gitlab_deploy_token into project/group and gitlab_branch_protection into CE/EE variants, and removes deprecated gitlab_project boolean/mirror attributes per the provider's 19.0 upgrade guide. See docs/UPGRADE-2.0.md. --- Readme.md | 10 +- docs/UPGRADE-2.0.md | 54 +++++ examples/terraform/versions.tf | 4 +- examples/terragrunt/gitlab/terragrunt.hcl | 2 +- examples/terragrunt/terragrunt.hcl | 4 +- main.tf | 248 +++++++++++----------- moved.tf | 65 ++++++ outputs.tf | 5 +- versions.tf | 4 +- 9 files changed, 257 insertions(+), 139 deletions(-) create mode 100644 docs/UPGRADE-2.0.md create mode 100644 moved.tf diff --git a/Readme.md b/Readme.md index 549d31c..673d9cd 100644 --- a/Readme.md +++ b/Readme.md @@ -15,8 +15,8 @@ This Terraform module provides resources to manage GitLab groups, projects, inte ## Requirements -- Terraform 1.5.7 or higher. -- GitLab Provider for Terraform 18.0.0 or higher. +- Terraform 1.8.0 or higher. +- GitLab Provider for Terraform 19.0.0 or higher. See [docs/UPGRADE-2.0.md](docs/UPGRADE-2.0.md) if upgrading from a 18.x-based release of this module. ## Usage @@ -111,8 +111,8 @@ The module provides the following outputs: You can find examples in the examples/ directory for different use cases, such as managing multiple groups, configuring integrations, and using different YAML configurations. -- [terraform](https://github.com/opsworks-co/terraform-gitlab/tree/main/examples/terraform) - How to use module with terraform -- [terragrunt](https://github.com/opsworks-co/terraform-gitlab/tree/main/examples/terragrunt) - How to use module with terragrunt +- [terraform](https://github.com/Perun-Engineering/terraform-gitlab/tree/main/examples/terraform) - How to use module with terraform +- [terragrunt](https://github.com/Perun-Engineering/terraform-gitlab/tree/main/examples/terragrunt) - How to use module with terragrunt ## Authors @@ -120,4 +120,4 @@ Module is maintained by [Serhii Kaidalov](https://github.com/wiseelf). ## License -Apache 2 Licensed. See [LICENSE](https://github.com/opsworks-co/terraform-gitlab/tree/main/LICENSE) for full details. +Apache 2 Licensed. See [LICENSE](https://github.com/Perun-Engineering/terraform-gitlab/tree/main/LICENSE) for full details. diff --git a/docs/UPGRADE-2.0.md b/docs/UPGRADE-2.0.md new file mode 100644 index 0000000..38fb731 --- /dev/null +++ b/docs/UPGRADE-2.0.md @@ -0,0 +1,54 @@ +# Upgrade to 2.x + +This update bumps the GitLab provider requirement from `18.4.1` to `19.1.0` to pick up +GitLab 19.0 support. The provider's own [19.0 upgrade guide](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/guides/version-19.0-upgrade) +required matching changes in this module. `moved.tf` handles the resource-type renames +automatically; no manual `terraform state mv` is required, but a plan should be reviewed +before applying since several resources are recreated under new addresses. + +Requires Terraform `>= 1.8` (needed for `moved` blocks across different resource types). + +## Resource renames (handled by `moved.tf`) + +- `gitlab_project_mirror` -> `gitlab_project_push_mirror` +- `gitlab_integration_emails_on_push` -> `gitlab_project_integration_emails_on_push` +- `gitlab_integration_external_wiki` -> `gitlab_project_integration_external_wiki` +- `gitlab_integration_github` -> `gitlab_project_integration_github` +- `gitlab_integration_jira` -> `gitlab_project_integration_jira` +- `gitlab_integration_microsoft_teams` -> `gitlab_project_integration_microsoft_teams` +- `gitlab_integration_pipelines_email` -> `gitlab_project_integration_pipelines_email` +- `gitlab_deploy_token` split into `gitlab_project_deploy_token` and `gitlab_group_deploy_token` +- `gitlab_branch_protection` split into `gitlab_branch_protection.ce` and `gitlab_branch_protection.ee`, + selected automatically by `var.tier` + +## ⚠️ Breaking changes to `gitlab_projects` input + +The following keys on a project entry no longer have any effect, because the provider +removed the underlying attributes. Use the replacement listed instead: + +| Removed key | Replacement | +|---|---| +| `issues_enabled` | `issues_access_level` (`"enabled"` / `"disabled"`) | +| `merge_requests_enabled` | `merge_requests_access_level` | +| `wiki_enabled` | `wiki_access_level` | +| `snippets_enabled` | `snippets_access_level` | +| `restrict_user_defined_variables` | `ci_pipeline_variables_minimum_override_role` (`false` -> `"developer"`, `true` -> `"maintainer"`) | +| `tags` | `topics` | +| `import_url`, `import_url_username`, `import_url_password`, `mirror_trigger_builds`, `only_mirror_protected_branches`, `mirror_overwrites_diverged_branches` | still supported, but now provisioned via a new `gitlab_project_pull_mirror` resource instead of `gitlab_project` attributes. Set `mirror: true` and `import_url` on the project as before. | + +## `gitlab_branch_protection` (CE vs EE) + +The provider no longer allows `push_access_level`/`merge_access_level`/`unprotect_access_level` +and `allowed_to_push`/`allowed_to_merge`/`allowed_to_unprotect` on the same resource. This module +now picks the correct resource automatically based on `var.tier`: + +- `tier = "free"` -> CE resource, honors `push_access_level` / `merge_access_level` on a branch. +- `tier = "premium"` / `"ultimate"` -> EE resource, honors `allowed_to_push` / `allowed_to_merge` / + `allowed_to_unprotect` / `code_owner_approval_required` on a branch. `unprotect_access_level` is + no longer configurable in either mode (removed by the provider). + +## `gitlab_project_protected_environment` + +`deploy_access_levels` blocks were replaced by a single `deploy_access_levels_attribute` list. +No input schema changes; `project.settings.protected_environments[].deploy_access_levels` is +unchanged. diff --git a/examples/terraform/versions.tf b/examples/terraform/versions.tf index f131a33..69cbc50 100644 --- a/examples/terraform/versions.tf +++ b/examples/terraform/versions.tf @@ -2,8 +2,8 @@ terraform { required_providers { gitlab = { source = "gitlabhq/gitlab" - version = ">= 17.3.0" + version = ">= 19.0.0" } } - required_version = ">= 1.3.0" + required_version = ">= 1.8.0" } diff --git a/examples/terragrunt/gitlab/terragrunt.hcl b/examples/terragrunt/gitlab/terragrunt.hcl index 29494af..be18909 100644 --- a/examples/terragrunt/gitlab/terragrunt.hcl +++ b/examples/terragrunt/gitlab/terragrunt.hcl @@ -1,5 +1,5 @@ terraform { - #source = "git@github.com:opsworks-co/terraform-gitlab.git//.?ref=${include.root.locals.terraform-gitlab}" + #source = "git@github.com:Perun-Engineering/terraform-gitlab.git//.?ref=${include.root.locals.terraform-gitlab}" source = get_repo_root() } diff --git a/examples/terragrunt/terragrunt.hcl b/examples/terragrunt/terragrunt.hcl index 4ae9b60..2592954 100644 --- a/examples/terragrunt/terragrunt.hcl +++ b/examples/terragrunt/terragrunt.hcl @@ -2,10 +2,10 @@ locals { # iam_role = "arn:aws:iam::012345678912:role/terragrunt" # session_name = "gitlab-terragrunt-012345678912" # Modules version (sorted a-z) - terraform-gitlab = "v0.1.0" # https://github.com/opsworks-co/terraform-gitlab + terraform-gitlab = "v1.4.0" # https://github.com/Perun-Engineering/terraform-gitlab } -terraform_version_constraint = "= 1.5.7" +terraform_version_constraint = ">= 1.8.0" terragrunt_version_constraint = "= 0.67.1" # iam_role = local.iam_role diff --git a/main.tf b/main.tf index 4d11ff0..760251e 100644 --- a/main.tf +++ b/main.tf @@ -558,13 +558,9 @@ resource "gitlab_project" "this" { forking_access_level = lookup(each.value, "forking_access_level", null) group_runners_enabled = lookup(each.value, "group_runners_enabled", null) group_with_project_templates_id = lookup(each.value, "group_with_project_templates_id", null) - import_url = lookup(each.value, "import_url", null) - import_url_password = lookup(each.value, "import_url_password", null) - import_url_username = lookup(each.value, "import_url_username", null) infrastructure_access_level = lookup(each.value, "infrastructure_access_level", null) initialize_with_readme = lookup(each.value, "initialize_with_readme", null) issues_access_level = lookup(each.value, "issues_access_level", null) - issues_enabled = lookup(each.value, "issues_enabled", true) issues_template = lookup(each.value, "issues_template", null) keep_latest_artifact = lookup(each.value, "keep_latest_artifact", null) lfs_enabled = lookup(each.value, "lfs_enabled", null) @@ -572,17 +568,12 @@ resource "gitlab_project" "this" { merge_method = lookup(each.value, "merge_method", null) merge_pipelines_enabled = lookup(each.value, "merge_pipelines_enabled", null) merge_requests_access_level = lookup(each.value, "merge_requests_access_level", null) - merge_requests_enabled = lookup(each.value, "merge_requests_enabled", true) merge_requests_template = lookup(each.value, "merge_requests_template", null) merge_trains_enabled = lookup(each.value, "merge_trains_enabled", null) - mirror = lookup(each.value, "mirror", null) - mirror_overwrites_diverged_branches = lookup(each.value, "mirror_overwrites_diverged_branches", null) - mirror_trigger_builds = lookup(each.value, "mirror_trigger_builds", null) monitor_access_level = lookup(each.value, "monitor_access_level", null) mr_default_target_self = lookup(each.value, "mr_default_target_self", null) only_allow_merge_if_all_discussions_are_resolved = lookup(each.value, "only_allow_merge_if_all_discussions_are_resolved", null) only_allow_merge_if_pipeline_succeeds = lookup(each.value, "only_allow_merge_if_pipeline_succeeds", null) - only_mirror_protected_branches = lookup(each.value, "only_mirror_protected_branches", null) packages_enabled = lookup(each.value, "packages_enabled", null) pages_access_level = lookup(each.value, "pages_access_level", null) path = lookup(each.value, "path", null) @@ -629,29 +620,26 @@ resource "gitlab_project" "this" { } } - releases_access_level = lookup(each.value, "releases_access_level", null) - remove_source_branch_after_merge = lookup(each.value, "remove_source_branch_after_merge", null) - repository_access_level = lookup(each.value, "repository_access_level", null) - repository_storage = lookup(each.value, "repository_storage", null) - request_access_enabled = lookup(each.value, "request_access_enabled", null) - requirements_access_level = lookup(each.value, "requirements_access_level", null) - resolve_outdated_diff_discussions = lookup(each.value, "resolve_outdated_diff_discussions", null) - restrict_user_defined_variables = lookup(each.value, "restrict_user_defined_variables", null) - security_and_compliance_access_level = lookup(each.value, "security_and_compliance_access_level", null) - shared_runners_enabled = lookup(each.value, "shared_runners_enabled", null) - skip_wait_for_default_branch_protection = lookup(each.value, "skip_wait_for_default_branch_protection", null) - snippets_access_level = lookup(each.value, "snippets_access_level", null) - snippets_enabled = lookup(each.value, "snippets_enabled", null) - squash_commit_template = lookup(each.value, "squash_commit_template", null) - squash_option = lookup(each.value, "squash_option", null) - suggestion_commit_message = lookup(each.value, "suggestion_commit_message", null) - tags = lookup(each.value, "tags", null) - template_name = lookup(each.value, "template_name", null) - template_project_id = lookup(each.value, "template_project_id", null) - topics = lookup(each.value, "topics", null) - use_custom_template = lookup(each.value, "use_custom_template", null) - wiki_access_level = lookup(each.value, "wiki_access_level", null) - wiki_enabled = lookup(each.value, "wiki_enabled", null) + ci_pipeline_variables_minimum_override_role = lookup(each.value, "ci_pipeline_variables_minimum_override_role", null) + releases_access_level = lookup(each.value, "releases_access_level", null) + remove_source_branch_after_merge = lookup(each.value, "remove_source_branch_after_merge", null) + repository_access_level = lookup(each.value, "repository_access_level", null) + repository_storage = lookup(each.value, "repository_storage", null) + request_access_enabled = lookup(each.value, "request_access_enabled", null) + requirements_access_level = lookup(each.value, "requirements_access_level", null) + resolve_outdated_diff_discussions = lookup(each.value, "resolve_outdated_diff_discussions", null) + security_and_compliance_access_level = lookup(each.value, "security_and_compliance_access_level", null) + shared_runners_enabled = lookup(each.value, "shared_runners_enabled", null) + skip_wait_for_default_branch_protection = lookup(each.value, "skip_wait_for_default_branch_protection", null) + snippets_access_level = lookup(each.value, "snippets_access_level", null) + squash_commit_template = lookup(each.value, "squash_commit_template", null) + squash_option = lookup(each.value, "squash_option", null) + suggestion_commit_message = lookup(each.value, "suggestion_commit_message", null) + template_name = lookup(each.value, "template_name", null) + template_project_id = lookup(each.value, "template_project_id", null) + topics = lookup(each.value, "topics", null) + use_custom_template = lookup(each.value, "use_custom_template", null) + wiki_access_level = lookup(each.value, "wiki_access_level", null) } resource "gitlab_project_access_token" "this" { @@ -991,7 +979,7 @@ resource "gitlab_project_milestone" "this" { state = lookup(each.value.milestone, "state", "active") # Default state if not specified } -resource "gitlab_project_mirror" "this" { +resource "gitlab_project_push_mirror" "this" { for_each = merge([ for project in var.gitlab_projects : { for mirror in [lookup(project.settings, "mirror", null)] : @@ -1014,6 +1002,23 @@ resource "gitlab_project_mirror" "this" { only_protected_branches = each.value.only_protected_branches } +# Pull mirroring, replaces the removed gitlab_project import_url/mirror* attributes +resource "gitlab_project_pull_mirror" "this" { + for_each = { + for project in var.gitlab_projects : + "${project.namespace}/${project.name}" => project + if lookup(project, "mirror", null) == true && lookup(project, "import_url", null) != null + } + + project = gitlab_project.this[each.key].id + url = each.value.import_url + auth_user = lookup(each.value, "import_url_username", null) + auth_password = lookup(each.value, "import_url_password", null) + mirror_trigger_builds = lookup(each.value, "mirror_trigger_builds", null) + only_mirror_protected_branches = lookup(each.value, "only_mirror_protected_branches", null) + mirror_overwrites_diverged_branches = lookup(each.value, "mirror_overwrites_diverged_branches", null) +} + resource "gitlab_project_protected_environment" "this" { for_each = merge([ for project in var.gitlab_projects : { @@ -1031,33 +1036,14 @@ resource "gitlab_project_protected_environment" "this" { environment = each.value.environment.environment project = gitlab_project.this["${each.value.project_namespace}/${each.value.project_name}"].id - - # Dynamic block for access level - dynamic "deploy_access_levels" { - for_each = [for lvl in try(each.value.environment.deploy_access_levels, []) : lvl if lookup(lvl, "access_level", null) != null] - iterator = lvl - content { - access_level = try(lvl.value.access_level, null) + # List of objects, replaces the removed deploy_access_levels nested blocks + deploy_access_levels_attribute = [ + for lvl in try(each.value.environment.deploy_access_levels, []) : { + access_level = lookup(lvl, "access_level", null) + group_id = lookup(lvl, "group", null) != null && contains(keys(local.exists_groups), lvl.group) ? local.exists_groups[lvl.group][0].group_id : null + user_id = lookup(lvl, "user_email", null) != null && contains(keys(local.exists_users), lvl.user_email) ? local.exists_users[lvl.user_email].id : null } - } - - # Dynamic block for group_id - dynamic "deploy_access_levels" { - for_each = [for lvl in try(each.value.environment.deploy_access_levels, []) : lvl if lookup(lvl, "group", null) != null] - iterator = lvl - content { - group_id = contains(keys(local.exists_groups), lvl.value.group) ? local.exists_groups[lvl.value.group][0].group_id : null - } - } - - # Dynamic block for user_id - dynamic "deploy_access_levels" { - for_each = [for lvl in try(each.value.environment.deploy_access_levels, []) : lvl if lookup(lvl, "user_email", null) != null] - iterator = lvl - content { - user_id = contains(keys(local.exists_users), lvl.value.user_email) ? local.exists_users[lvl.value.user_email].id : null - } - } + ] # Set approval rules directly as a list of objects approval_rules = flatten([ @@ -1297,7 +1283,8 @@ resource "gitlab_branch" "this" { } # Create GitLab Branch Protection for Protected Branches -resource "gitlab_branch_protection" "this" { +# CE: push_access_level/merge_access_level are not available on GitLab Enterprise instances +resource "gitlab_branch_protection" "ce" { for_each = merge([ for project in var.gitlab_projects : { for branch in lookup(project.settings, "branches", []) : @@ -1305,45 +1292,56 @@ resource "gitlab_branch_protection" "this" { project_name = project.name project_namespace = project.namespace branch = branch - } if lookup(branch, "protected", false) == true # Only create protection if `protected` is set to true + } if lookup(branch, "protected", false) == true && !contains(["premium", "ultimate"], lower(var.tier)) + } + ]...) + + project = gitlab_project.this["${each.value.project_namespace}/${each.value.project_name}"].id + branch = each.value.branch.name + push_access_level = lookup(each.value.branch, "push_access_level", "maintainer") + merge_access_level = lookup(each.value.branch, "merge_access_level", "maintainer") + allow_force_push = lookup(each.value.branch, "allow_force_push", false) +} + +# EE: allowed_to_push/allowed_to_merge/allowed_to_unprotect/code_owner_approval_required require GitLab Enterprise +resource "gitlab_branch_protection" "ee" { + for_each = merge([ + for project in var.gitlab_projects : { + for branch in lookup(project.settings, "branches", []) : + "${project.namespace}-${project.name}-${branch.name}" => { + project_name = project.name + project_namespace = project.namespace + branch = branch + } if lookup(branch, "protected", false) == true && contains(["premium", "ultimate"], lower(var.tier)) } ]...) project = gitlab_project.this["${each.value.project_namespace}/${each.value.project_name}"].id branch = each.value.branch.name - push_access_level = lookup(each.value.branch, "push_access_level", "maintainer") - merge_access_level = lookup(each.value.branch, "merge_access_level", "maintainer") - unprotect_access_level = lookup(each.value.branch, "unprotect_access_level", "admin") allow_force_push = lookup(each.value.branch, "allow_force_push", false) code_owner_approval_required = lookup(each.value.branch, "code_owner_approval_required", true) - # Dynamic blocks for allowed_to_push - dynamic "allowed_to_push" { - for_each = lookup(each.value.branch, "allowed_to_push", []) - content { - user_id = contains(keys(local.exists_users), lookup(allowed_to_push.value, "user_email", "")) ? local.exists_users[allowed_to_push.value.user_email].id : null - group_id = contains(keys(local.exists_groups), lookup(allowed_to_push.value, "group", "")) ? local.exists_groups[allowed_to_push.value.group][0].group_id : null - deploy_key_id = lookup(allowed_to_push.value, "deploy_key_title", null) != null && contains(keys(local.exists_deploy_keys), "${each.value.project_namespace}-${each.value.project_name}-${lookup(allowed_to_push.value, "deploy_key_title", "")}") ? local.exists_deploy_keys["${each.value.project_namespace}-${each.value.project_name}-${lookup(allowed_to_push.value, "deploy_key_title", "")}"].deploy_key_id : null + allowed_to_push = [ + for entry in lookup(each.value.branch, "allowed_to_push", []) : { + user_id = contains(keys(local.exists_users), lookup(entry, "user_email", "")) ? local.exists_users[entry.user_email].id : null + group_id = contains(keys(local.exists_groups), lookup(entry, "group", "")) ? local.exists_groups[entry.group][0].group_id : null + deploy_key_id = lookup(entry, "deploy_key_title", null) != null && contains(keys(local.exists_deploy_keys), "${each.value.project_namespace}-${each.value.project_name}-${lookup(entry, "deploy_key_title", "")}") ? local.exists_deploy_keys["${each.value.project_namespace}-${each.value.project_name}-${lookup(entry, "deploy_key_title", "")}"].deploy_key_id : null } - } + ] - # Dynamic blocks for allowed_to_merge - dynamic "allowed_to_merge" { - for_each = lookup(each.value.branch, "allowed_to_merge", []) - content { - user_id = contains(keys(local.exists_users), lookup(allowed_to_merge.value, "user_email", "")) ? local.exists_users[allowed_to_merge.value.user_email].id : null - group_id = contains(keys(local.exists_groups), lookup(allowed_to_merge.value, "group", "")) ? local.exists_groups[allowed_to_merge.value.group][0].group_id : null + allowed_to_merge = [ + for entry in lookup(each.value.branch, "allowed_to_merge", []) : { + user_id = contains(keys(local.exists_users), lookup(entry, "user_email", "")) ? local.exists_users[entry.user_email].id : null + group_id = contains(keys(local.exists_groups), lookup(entry, "group", "")) ? local.exists_groups[entry.group][0].group_id : null } - } + ] - # Dynamic blocks for allowed_to_unprotect - dynamic "allowed_to_unprotect" { - for_each = lookup(each.value.branch, "allowed_to_unprotect", []) - content { - user_id = contains(keys(local.exists_users), lookup(allowed_to_unprotect.value, "user_email", "")) ? local.exists_users[allowed_to_unprotect.value.user_email].id : null - group_id = contains(keys(local.exists_groups), lookup(allowed_to_unprotect.value, "group", "")) ? local.exists_groups[allowed_to_unprotect.value.group][0].group_id : null + allowed_to_unprotect = [ + for entry in lookup(each.value.branch, "allowed_to_unprotect", []) : { + user_id = contains(keys(local.exists_users), lookup(entry, "user_email", "")) ? local.exists_users[entry.user_email].id : null + group_id = contains(keys(local.exists_groups), lookup(entry, "group", "")) ? local.exists_groups[entry.group][0].group_id : null } - } + ] } resource "gitlab_repository_file" "this" { @@ -1385,7 +1383,7 @@ resource "gitlab_repository_file" "this" { } ##integrations -resource "gitlab_integration_emails_on_push" "this" { +resource "gitlab_project_integration_emails_on_push" "this" { for_each = { for project in var.gitlab_projects : "${project.namespace}-${project.name}-${lookup(project.settings.integration_emails_on_push, "recipients", "no-recipient")}" => { @@ -1405,7 +1403,7 @@ resource "gitlab_integration_emails_on_push" "this" { tag_push_events = each.value.integration.tag_push_events } -resource "gitlab_integration_external_wiki" "this" { +resource "gitlab_project_integration_external_wiki" "this" { for_each = { for project in var.gitlab_projects : "${project.namespace}-${project.name}-${lookup(project.settings.integration_external_wiki, "external_wiki_url", "no-url")}" => { @@ -1420,7 +1418,7 @@ resource "gitlab_integration_external_wiki" "this" { external_wiki_url = each.value.integration.external_wiki_url } -resource "gitlab_integration_github" "this" { +resource "gitlab_project_integration_github" "this" { for_each = { for project in var.gitlab_projects : "${project.namespace}-${project.name}-${lookup(project.settings.integration_github, "repository_url", "no-url")}" => { @@ -1437,7 +1435,7 @@ resource "gitlab_integration_github" "this" { static_context = lookup(each.value.integration, "static_context", false) } -resource "gitlab_integration_jira" "this" { +resource "gitlab_project_integration_jira" "this" { for_each = { for project in var.gitlab_projects : "${project.namespace}-${project.name}-${lookup(project.settings.integration_jira, "url", "no-url")}" => { @@ -1466,7 +1464,7 @@ resource "gitlab_integration_jira" "this" { use_inherited_settings = each.value.integration.use_inherited_settings } -resource "gitlab_integration_microsoft_teams" "this" { +resource "gitlab_project_integration_microsoft_teams" "this" { for_each = { for project in var.gitlab_projects : "${project.namespace}-${project.name}-${lookup(project.settings.integration_microsoft_teams, "webhook", "no-webhook")}" => { @@ -1492,7 +1490,7 @@ resource "gitlab_integration_microsoft_teams" "this" { wiki_page_events = each.value.integration.wiki_page_events } -resource "gitlab_integration_pipelines_email" "this" { +resource "gitlab_project_integration_pipelines_email" "this" { for_each = { for project in var.gitlab_projects : "${project.namespace}-${project.name}-${join("-", lookup(project.settings.integration_pipelines_email, "recipients", ["no-recipients"]))}" => { @@ -1545,43 +1543,41 @@ resource "gitlab_integration_slack" "this" { } # Combine Project and Group Deploy Tokens -resource "gitlab_deploy_token" "this" { - for_each = merge( - merge([ - for project in var.gitlab_projects : { - for token in lookup(project.settings, "deploy_tokens", []) : - "project-${project.namespace}-${project.name}-${token.name}" => { - type = "project" - name = token.name - namespace = project.namespace - entity_name = project.name - deploy_token = token - entity_id = gitlab_project.this["${project.namespace}/${project.name}"].id - } - } - ]...), - merge([ - for group in var.gitlab_groups : { - for token in lookup(group.settings, "deploy_tokens", []) : ( - contains(keys(group), "parent") - ? "group-${group.parent}/${group.name}-${token.name}" # Include parent in the key if it exists - : "group-${group.name}-${token.name}" # Fallback to group name only if no parent - ) => { - type = "group" - parent = lookup(group, "parent", null) - name = token.name - entity_name = contains(keys(group), "parent") ? "${group.parent}/${group.name}" : group.name - deploy_token = token - entity_id = contains(keys(group), "parent") ? gitlab_group.subgroups["${group.parent}/${group.name}"].id : gitlab_group.parent_groups[group.name].id - } +# Project deploy tokens +resource "gitlab_project_deploy_token" "this" { + for_each = merge([ + for project in var.gitlab_projects : { + for token in lookup(project.settings, "deploy_tokens", []) : + "project-${project.namespace}-${project.name}-${token.name}" => { + deploy_token = token + entity_id = gitlab_project.this["${project.namespace}/${project.name}"].id } - ]...) - ) + } + ]...) + + project = each.value.entity_id + name = each.value.deploy_token.name + scopes = each.value.deploy_token.scopes + expires_at = lookup(each.value.deploy_token, "expires_at", null) + username = lookup(each.value.deploy_token, "username", null) +} - # Conditional assignment for project or group - project = each.value.type == "project" ? each.value.entity_id : null - group = each.value.type == "group" ? each.value.entity_id : null +# Group deploy tokens +resource "gitlab_group_deploy_token" "this" { + for_each = merge([ + for group in var.gitlab_groups : { + for token in lookup(group.settings, "deploy_tokens", []) : ( + contains(keys(group), "parent") + ? "group-${group.parent}/${group.name}-${token.name}" # Include parent in the key if it exists + : "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 + } + } + ]...) + group = each.value.entity_id name = each.value.deploy_token.name scopes = each.value.deploy_token.scopes expires_at = lookup(each.value.deploy_token, "expires_at", null) diff --git a/moved.tf b/moved.tf new file mode 100644 index 0000000..50d12cd --- /dev/null +++ b/moved.tf @@ -0,0 +1,65 @@ +# State migration for the GitLab provider v19.0 upgrade. +# See docs/UPGRADE-2.0.md and the provider's upgrade guide: +# https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/guides/version-19.0-upgrade +# +# Requires Terraform >= 1.8 for moved blocks across different resource types. + +moved { + from = gitlab_project_mirror.this + to = gitlab_project_push_mirror.this +} + +moved { + from = gitlab_integration_emails_on_push.this + to = gitlab_project_integration_emails_on_push.this +} + +moved { + from = gitlab_integration_external_wiki.this + to = gitlab_project_integration_external_wiki.this +} + +moved { + from = gitlab_integration_github.this + to = gitlab_project_integration_github.this +} + +moved { + from = gitlab_integration_jira.this + to = gitlab_project_integration_jira.this +} + +moved { + from = gitlab_integration_microsoft_teams.this + to = gitlab_project_integration_microsoft_teams.this +} + +moved { + from = gitlab_integration_pipelines_email.this + to = gitlab_project_integration_pipelines_email.this +} + +# gitlab_deploy_token split into project- and group-scoped resources. +# Instance keys are prefixed "project-"/"group-" and disjoint between the two, +# so each old instance matches exactly one of the two destinations. +moved { + from = gitlab_deploy_token.this + to = gitlab_project_deploy_token.this +} + +moved { + from = gitlab_deploy_token.this + to = gitlab_group_deploy_token.this +} + +# gitlab_branch_protection split into CE/EE resources gated by var.tier. +# Only one of the two ever has matching instances for a given module invocation. +moved { + from = gitlab_branch_protection.this + to = gitlab_branch_protection.ce +} + +moved { + from = gitlab_branch_protection.this + to = gitlab_branch_protection.ee +} diff --git a/outputs.tf b/outputs.tf index df04bea..fc62c51 100644 --- a/outputs.tf +++ b/outputs.tf @@ -18,7 +18,10 @@ output "project_ids" { output "deploy_token_ids" { description = "IDs of created deploy tokens." - value = [for token in gitlab_deploy_token.this : token.id] + value = concat( + [for token in gitlab_project_deploy_token.this : token.id], + [for token in gitlab_group_deploy_token.this : token.id], + ) } output "pipeline_schedule_ids" { diff --git a/versions.tf b/versions.tf index cea86bc..2a73634 100644 --- a/versions.tf +++ b/versions.tf @@ -2,8 +2,8 @@ terraform { required_providers { gitlab = { source = "gitlabhq/gitlab" - version = "= 18.4.1" + version = "= 19.1.0" } } - required_version = ">= 1.4.0" + required_version = ">= 1.8.0" } From f3e82b9294ed0f95f3b68af61ae13b0b549e4c9e Mon Sep 17 00:00:00 2001 From: wiseelf Date: Tue, 7 Jul 2026 12:00:28 +1000 Subject: [PATCH 2/6] fix: Bump terragrunt_version_constraint to 0.99.5 in terragrunt example --- examples/terragrunt/terragrunt.hcl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/terragrunt/terragrunt.hcl b/examples/terragrunt/terragrunt.hcl index 2592954..3ef7c8c 100644 --- a/examples/terragrunt/terragrunt.hcl +++ b/examples/terragrunt/terragrunt.hcl @@ -6,7 +6,7 @@ locals { } terraform_version_constraint = ">= 1.8.0" -terragrunt_version_constraint = "= 0.67.1" +terragrunt_version_constraint = "= 0.99.5" # iam_role = local.iam_role # Configure Terragrunt to automatically store tfstate files in an S3 bucket From 3816067f4db02b9f0b52d31f17dbc2a9a0db268e Mon Sep 17 00:00:00 2001 From: wiseelf Date: Tue, 7 Jul 2026 12:22:18 +1000 Subject: [PATCH 3/6] fix: Drop ambiguous moved blocks for split resources Terraform rejects two moved blocks sharing the same 'from' address with 'Ambiguous move statements', so the gitlab_deploy_token and gitlab_branch_protection splits can't be automated via moved.tf. Keep the 7 safe 1:1 resource renames there, and document the required manual terraform state mv commands for the two splits in docs/UPGRADE-2.0.md. --- docs/UPGRADE-2.0.md | 43 +++++++++++++++++++++++++++++++++++-------- moved.tf | 29 +++++------------------------ 2 files changed, 40 insertions(+), 32 deletions(-) diff --git a/docs/UPGRADE-2.0.md b/docs/UPGRADE-2.0.md index 38fb731..9fa35d4 100644 --- a/docs/UPGRADE-2.0.md +++ b/docs/UPGRADE-2.0.md @@ -2,13 +2,12 @@ This update bumps the GitLab provider requirement from `18.4.1` to `19.1.0` to pick up GitLab 19.0 support. The provider's own [19.0 upgrade guide](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/guides/version-19.0-upgrade) -required matching changes in this module. `moved.tf` handles the resource-type renames -automatically; no manual `terraform state mv` is required, but a plan should be reviewed -before applying since several resources are recreated under new addresses. +required matching changes in this module. -Requires Terraform `>= 1.8` (needed for `moved` blocks across different resource types). +Requires Terraform `>= 1.8` (needed for the `moved` blocks in `moved.tf`, which move +resources across different resource types). -## Resource renames (handled by `moved.tf`) +## Resource renames (handled automatically by `moved.tf`) - `gitlab_project_mirror` -> `gitlab_project_push_mirror` - `gitlab_integration_emails_on_push` -> `gitlab_project_integration_emails_on_push` @@ -17,9 +16,37 @@ Requires Terraform `>= 1.8` (needed for `moved` blocks across different resource - `gitlab_integration_jira` -> `gitlab_project_integration_jira` - `gitlab_integration_microsoft_teams` -> `gitlab_project_integration_microsoft_teams` - `gitlab_integration_pipelines_email` -> `gitlab_project_integration_pipelines_email` -- `gitlab_deploy_token` split into `gitlab_project_deploy_token` and `gitlab_group_deploy_token` -- `gitlab_branch_protection` split into `gitlab_branch_protection.ce` and `gitlab_branch_protection.ee`, - selected automatically by `var.tier` + +No action needed for these; `terraform plan` will show them moved in place. + +## Resource splits (require a manual `terraform state mv`) + +Terraform rejects two `moved` blocks that share the same `from` address with +`Error: Ambiguous move statements`, even when the destination `for_each` sets are +disjoint — so these two splits can't be automated in `moved.tf`. Without a manual +move, `terraform plan` will destroy the old resource and create the new one. + +### `gitlab_deploy_token` -> `gitlab_project_deploy_token` / `gitlab_group_deploy_token` + +Move each instance to the resource matching its scope (`project` vs `group` in your +`gitlab_deploy_token.this` addresses): + +```bash +terraform state mv 'module.gitlab.gitlab_deploy_token.this["project-ns-proj-mytoken"]' 'module.gitlab.gitlab_project_deploy_token.this["project-ns-proj-mytoken"]' +terraform state mv 'module.gitlab.gitlab_deploy_token.this["group-mygroup-mytoken"]' 'module.gitlab.gitlab_group_deploy_token.this["group-mygroup-mytoken"]' +``` + +### `gitlab_branch_protection` -> `.ce` / `.ee` + +Move each instance to `.ce` if your `var.tier` is `free`, or `.ee` if `premium`/`ultimate`: + +```bash +# tier = "free" +terraform state mv 'module.gitlab.gitlab_branch_protection.this["ns-proj-main"]' 'module.gitlab.gitlab_branch_protection.ce["ns-proj-main"]' + +# tier = "premium" / "ultimate" +terraform state mv 'module.gitlab.gitlab_branch_protection.this["ns-proj-main"]' 'module.gitlab.gitlab_branch_protection.ee["ns-proj-main"]' +``` ## ⚠️ Breaking changes to `gitlab_projects` input diff --git a/moved.tf b/moved.tf index 50d12cd..98ba461 100644 --- a/moved.tf +++ b/moved.tf @@ -39,27 +39,8 @@ moved { to = gitlab_project_integration_pipelines_email.this } -# gitlab_deploy_token split into project- and group-scoped resources. -# Instance keys are prefixed "project-"/"group-" and disjoint between the two, -# so each old instance matches exactly one of the two destinations. -moved { - from = gitlab_deploy_token.this - to = gitlab_project_deploy_token.this -} - -moved { - from = gitlab_deploy_token.this - to = gitlab_group_deploy_token.this -} - -# gitlab_branch_protection split into CE/EE resources gated by var.tier. -# Only one of the two ever has matching instances for a given module invocation. -moved { - from = gitlab_branch_protection.this - to = gitlab_branch_protection.ce -} - -moved { - from = gitlab_branch_protection.this - to = gitlab_branch_protection.ee -} +# gitlab_deploy_token (split into project/group) and gitlab_branch_protection +# (split into ce/ee) are NOT covered here: Terraform rejects two moved blocks +# sharing the same "from" with "Error: Ambiguous move statements", even when +# the destination for_each sets are disjoint. See docs/UPGRADE-2.0.md for the +# manual `terraform state mv` commands required for those two resources. From ed05d4cab9ca175d4abcc823e9be94922b44c840 Mon Sep 17 00:00:00 2001 From: wiseelf Date: Tue, 7 Jul 2026 12:34:52 +1000 Subject: [PATCH 4/6] fix: Forward access_level in gitlab_branch_protection ee allowed_to_* lists allowed_to_push/allowed_to_merge/allowed_to_unprotect entries only computed user_id/group_id/deploy_key_id, silently dropping access_level. Since EE no longer honors push_access_level/merge_access_level at all, callers now rely on an access_level entry inside allowed_to_push/allowed_to_merge, which the module previously discarded, producing all-null entries and provider errors. --- main.tf | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/main.tf b/main.tf index 760251e..0d2ed73 100644 --- a/main.tf +++ b/main.tf @@ -1323,6 +1323,7 @@ resource "gitlab_branch_protection" "ee" { allowed_to_push = [ for entry in lookup(each.value.branch, "allowed_to_push", []) : { + access_level = lookup(entry, "access_level", null) user_id = contains(keys(local.exists_users), lookup(entry, "user_email", "")) ? local.exists_users[entry.user_email].id : null group_id = contains(keys(local.exists_groups), lookup(entry, "group", "")) ? local.exists_groups[entry.group][0].group_id : null deploy_key_id = lookup(entry, "deploy_key_title", null) != null && contains(keys(local.exists_deploy_keys), "${each.value.project_namespace}-${each.value.project_name}-${lookup(entry, "deploy_key_title", "")}") ? local.exists_deploy_keys["${each.value.project_namespace}-${each.value.project_name}-${lookup(entry, "deploy_key_title", "")}"].deploy_key_id : null @@ -1331,15 +1332,17 @@ resource "gitlab_branch_protection" "ee" { allowed_to_merge = [ for entry in lookup(each.value.branch, "allowed_to_merge", []) : { - user_id = contains(keys(local.exists_users), lookup(entry, "user_email", "")) ? local.exists_users[entry.user_email].id : null - group_id = contains(keys(local.exists_groups), lookup(entry, "group", "")) ? local.exists_groups[entry.group][0].group_id : null + access_level = lookup(entry, "access_level", null) + user_id = contains(keys(local.exists_users), lookup(entry, "user_email", "")) ? local.exists_users[entry.user_email].id : null + group_id = contains(keys(local.exists_groups), lookup(entry, "group", "")) ? local.exists_groups[entry.group][0].group_id : null } ] allowed_to_unprotect = [ for entry in lookup(each.value.branch, "allowed_to_unprotect", []) : { - user_id = contains(keys(local.exists_users), lookup(entry, "user_email", "")) ? local.exists_users[entry.user_email].id : null - group_id = contains(keys(local.exists_groups), lookup(entry, "group", "")) ? local.exists_groups[entry.group][0].group_id : null + access_level = lookup(entry, "access_level", null) + user_id = contains(keys(local.exists_users), lookup(entry, "user_email", "")) ? local.exists_users[entry.user_email].id : null + group_id = contains(keys(local.exists_groups), lookup(entry, "group", "")) ? local.exists_groups[entry.group][0].group_id : null } ] } From d792d9dcb35db7e80c6f25e28e3c9642e2d7db44 Mon Sep 17 00:00:00 2001 From: wiseelf Date: Tue, 7 Jul 2026 12:36:06 +1000 Subject: [PATCH 5/6] fix: Replace deprecated require_password_to_approve attribute require_password_to_approve on gitlab_project_level_mr_approvals is deprecated in provider 19.x and removed in 20.0; use require_reauthentication_to_approve instead. --- docs/UPGRADE-2.0.md | 1 + main.tf | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/UPGRADE-2.0.md b/docs/UPGRADE-2.0.md index 9fa35d4..49f028a 100644 --- a/docs/UPGRADE-2.0.md +++ b/docs/UPGRADE-2.0.md @@ -62,6 +62,7 @@ removed the underlying attributes. Use the replacement listed instead: | `restrict_user_defined_variables` | `ci_pipeline_variables_minimum_override_role` (`false` -> `"developer"`, `true` -> `"maintainer"`) | | `tags` | `topics` | | `import_url`, `import_url_username`, `import_url_password`, `mirror_trigger_builds`, `only_mirror_protected_branches`, `mirror_overwrites_diverged_branches` | still supported, but now provisioned via a new `gitlab_project_pull_mirror` resource instead of `gitlab_project` attributes. Set `mirror: true` and `import_url` on the project as before. | +| `settings.level_mr_approvals[].require_password_to_approve` | `require_reauthentication_to_approve` (same meaning, provider-deprecated key removed in GitLab 20.0) | ## `gitlab_branch_protection` (CE vs EE) diff --git a/main.tf b/main.tf index 0d2ed73..f6f5892 100644 --- a/main.tf +++ b/main.tf @@ -934,7 +934,7 @@ resource "gitlab_project_level_mr_approvals" "this" { disable_overriding_approvers_per_merge_request = lookup(each.value.level_mr_approvals, "disable_overriding_approvers_per_merge_request", false) merge_requests_author_approval = lookup(each.value.level_mr_approvals, "merge_requests_author_approval", false) merge_requests_disable_committers_approval = lookup(each.value.level_mr_approvals, "merge_requests_disable_committers_approval", false) - require_password_to_approve = lookup(each.value.level_mr_approvals, "require_password_to_approve", false) + require_reauthentication_to_approve = lookup(each.value.level_mr_approvals, "require_reauthentication_to_approve", false) reset_approvals_on_push = lookup(each.value.level_mr_approvals, "reset_approvals_on_push", false) selective_code_owner_removals = lookup(each.value.level_mr_approvals, "selective_code_owner_removals", false) } From b9c71133fddcd2790c7d0466a25c5a832097e991 Mon Sep 17 00:00:00 2001 From: wiseelf Date: Tue, 7 Jul 2026 13:33:01 +1000 Subject: [PATCH 6/6] fix: Replace bulk gitlab_users/gitlab_groups lookups with scoped fetches data "gitlab_users" "this" {} and data "gitlab_groups" "this" {} paged the whole instance (720+ users here) and were non-deterministic between two reads a minute apart, flipping which user/group resolved for allowed_to_push, allowed_to_merge, membership, approval_rule, and protected_environment entries and tripping the provider's ExactlyOneOf validator. Replace both with data.gitlab_user/data.gitlab_group for_each, scoped to only the emails and group full_paths actually referenced across var.gitlab_projects. Groups already managed by this module invocation are merged in directly from gitlab_group.parent_groups/subgroups rather than looked up externally, since a brand-new group doesn't exist yet at plan time and would otherwise 404 the data source read. exists_users/exists_groups keep their existing shape so no other call site changes. --- main.tf | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 84 insertions(+), 9 deletions(-) diff --git a/main.tf b/main.tf index f6f5892..e967baa 100644 --- a/main.tf +++ b/main.tf @@ -480,11 +480,6 @@ resource "gitlab_group_variable" "this" { variable_type = lookup(each.value.variable, "variable_type", "env_var") } -# Data sources to retrieve users and groups from GitLab -data "gitlab_users" "this" {} - -data "gitlab_groups" "this" {} - # Locals to map users and groups for dynamic references locals { label_map = { for group_name, label in gitlab_group_label.this : label.name => label.label_id } @@ -502,16 +497,96 @@ locals { ] if contains(keys(lookup(group, "settings", {})), "share_groups") ])) - # Group by username for users; this should not have duplicates - exists_users = { for user in data.gitlab_users.this.users : user.email => user } + # Every email referenced by *_email keys across projects, so we look up exactly + # those users instead of paging through the whole instance (data.gitlab_users + # is non-deterministic on large instances: the user list can differ between two + # reads a minute apart, which flips which allowed_to_* entries resolve). + referenced_user_emails = toset(compact(flatten([ + for project in var.gitlab_projects : concat( + flatten([for rule in lookup(project.settings, "approval_rules", []) : lookup(rule, "user_emails", [])]), + [for member in lookup(project.settings, "memberships", []) : lookup(member, "user_email", null)], + flatten([ + for env in lookup(project.settings, "protected_environments", []) : concat( + [for lvl in lookup(env, "deploy_access_levels", []) : lookup(lvl, "user_email", null)], + [for rule in lookup(env, "approval_rules", []) : lookup(rule, "user_email", null)], + ) + ]), + flatten([ + for branch in lookup(project.settings, "branches", []) : concat( + [for entry in lookup(branch, "allowed_to_push", []) : lookup(entry, "user_email", null)], + [for entry in lookup(branch, "allowed_to_merge", []) : lookup(entry, "user_email", null)], + [for entry in lookup(branch, "allowed_to_unprotect", []) : lookup(entry, "user_email", null)], + ) + ]), + ) + ]))) + + # Group full_paths managed by this module invocation, excluded from the external + # group lookup below since they don't exist yet at plan time for a brand-new group. + managed_group_paths = toset(concat(keys(gitlab_group.parent_groups), keys(gitlab_group.subgroups))) + + # Same rationale as referenced_user_emails: look up exactly the group full_paths + # this config references, instead of paging through every group on the instance. + referenced_group_paths = toset([ + for path in compact(flatten([ + [for project in var.gitlab_projects : project.namespace], + flatten([for project in var.gitlab_projects : [for rule in lookup(project.settings, "approval_rules", []) : lookup(rule, "group_names", [])]]), + flatten([ + for project in var.gitlab_projects : [ + for env in lookup(project.settings, "protected_environments", []) : concat( + [for lvl in lookup(env, "deploy_access_levels", []) : lookup(lvl, "group", null)], + [for rule in lookup(env, "approval_rules", []) : lookup(rule, "group", null)], + ) + ] + ]), + [for project in var.gitlab_projects : [for sg in lookup(project.settings, "share_groups", []) : sg.group]], + flatten([ + for project in var.gitlab_projects : [ + for branch in lookup(project.settings, "branches", []) : concat( + [for entry in lookup(branch, "allowed_to_push", []) : lookup(entry, "group", null)], + [for entry in lookup(branch, "allowed_to_merge", []) : lookup(entry, "group", null)], + [for entry in lookup(branch, "allowed_to_unprotect", []) : lookup(entry, "group", null)], + ) + ] + ]), + ])) : path if !contains(local.managed_group_paths, path) + ]) + + # Groups managed by this module invocation, in the same [{ group_id = ... }] shape + # as the external lookup below, so every exists_groups[path][0].group_id call site + # resolves internally-managed groups too (not just ones that already existed). + 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 }] }, + ) - # Group by name for groups, allowing for duplicates - exists_groups = { for group in data.gitlab_groups.this.groups : group.full_path => group... } + # Group by name for groups, allowing for duplicates (keeps the [0] indexing used + # at every exists_groups[...] call site unchanged) + exists_groups = merge( + local.managed_groups_by_path, + { for path, group in data.gitlab_group.referenced : path => [group] }, + ) + + # Group by email for users; this should not have duplicates + exists_users = { for email, user in data.gitlab_user.referenced : email => user } # Map deploy keys by project namespace/name and key title for easy lookup exists_deploy_keys = { for key_id, key in gitlab_deploy_key.this : key_id => key } } +# Resolve only the users actually referenced by email across all project settings +data "gitlab_user" "referenced" { + for_each = local.referenced_user_emails + email = each.value + email_exact_match = true +} + +# Resolve only the groups actually referenced that aren't already managed by this module +data "gitlab_group" "referenced" { + for_each = local.referenced_group_paths + full_path = each.value +} + # Create GitLab projects dynamically resource "gitlab_project" "this" { for_each = {