Update unique constraint on tag name pattern for tag protection rules

🌽 What does this MR do and why?

This change modifies how container registry tag protection rules handle duplicate tag patterns within a project. Previously, the system completely prevented any duplicate tag patterns per project. Now it allows both "mutable" and "immutable" protection rules to coexist with the same tag pattern, but still prevents duplicates within each type.

🥦 Migration results

⬆️ Migration up
main: == [advisory_lock_connection] object_id: 139400, pg_backend_pid: 83885
main: == 20250715040428 UpdateContainerRegistryProtectionTagRulesPatternUniqueness: migrating
main: -- transaction_open?(nil)
main:    -> 0.0000s
main: -- view_exists?(:postgres_partitions)
main:    -> 0.0156s
main: -- indexes(:container_registry_protection_tag_rules)
main:    -> 0.0022s
main: -- execute("SET statement_timeout TO 0")
main:    -> 0.0004s
main: -- remove_index(:container_registry_protection_tag_rules, {:algorithm=>:concurrently, :name=>"unique_protection_tag_rules_project_id_and_tag_name_pattern"})
main:    -> 0.0017s
main: -- execute("RESET statement_timeout")
main:    -> 0.0004s
main: -- transaction_open?(nil)
main:    -> 0.0000s
main: -- view_exists?(:postgres_partitions)
main:    -> 0.0007s
main: -- index_exists?(:container_registry_protection_tag_rules, [:project_id, :tag_name_pattern], {:unique=>true, :where=>"minimum_access_level_for_push IS NULL AND minimum_access_level_for_delete IS NULL", :name=>"unique_protection_tag_rules_immutable", :algorithm=>:concurrently})
main:    -> 0.0012s
main: -- add_index(:container_registry_protection_tag_rules, [:project_id, :tag_name_pattern], {:unique=>true, :where=>"minimum_access_level_for_push IS NULL AND minimum_access_level_for_delete IS NULL", :name=>"unique_protection_tag_rules_immutable", :algorithm=>:concurrently})
main:    -> 0.0035s
main: -- transaction_open?(nil)
main:    -> 0.0000s
main: -- view_exists?(:postgres_partitions)
main:    -> 0.0003s
main: -- index_exists?(:container_registry_protection_tag_rules, [:project_id, :tag_name_pattern], {:unique=>true, :where=>"minimum_access_level_for_push IS NOT NULL AND minimum_access_level_for_delete IS NOT NULL", :name=>"unique_protection_tag_rules_mutable", :algorithm=>:concurrently})
main:    -> 0.0016s
main: -- add_index(:container_registry_protection_tag_rules, [:project_id, :tag_name_pattern], {:unique=>true, :where=>"minimum_access_level_for_push IS NOT NULL AND minimum_access_level_for_delete IS NOT NULL", :name=>"unique_protection_tag_rules_mutable", :algorithm=>:concurrently})
main:    -> 0.0023s
main: == 20250715040428 UpdateContainerRegistryProtectionTagRulesPatternUniqueness: migrated (0.0736s)

main: == [advisory_lock_connection] object_id: 139400, pg_backend_pid: 83885
⬇️ Migration down
main: == [advisory_lock_connection] object_id: 139200, pg_backend_pid: 83849
main: == 20250715040428 UpdateContainerRegistryProtectionTagRulesPatternUniqueness: reverting
main: -- transaction_open?(nil)
main:    -> 0.0000s
main: -- view_exists?(:postgres_partitions)
main:    -> 0.0189s
main: -- indexes(:container_registry_protection_tag_rules)
main:    -> 0.0023s
main: -- current_schema(nil)
main:    -> 0.0001s
main: -- transaction_open?(nil)
main:    -> 0.0000s
main: -- view_exists?(:postgres_partitions)
main:    -> 0.0006s
main: -- indexes(:container_registry_protection_tag_rules)
main:    -> 0.0012s
main: -- current_schema(nil)
main:    -> 0.0001s
main: -- transaction_open?(nil)
main:    -> 0.0000s
main: -- view_exists?(:postgres_partitions)
main:    -> 0.0003s
main: -- index_exists?(:container_registry_protection_tag_rules, [:project_id, :tag_name_pattern], {:unique=>true, :name=>"unique_protection_tag_rules_project_id_and_tag_name_pattern", :algorithm=>:concurrently})
main:    -> 0.0011s
main: -- execute("SET statement_timeout TO 0")
main:    -> 0.0007s
main: -- add_index(:container_registry_protection_tag_rules, [:project_id, :tag_name_pattern], {:unique=>true, :name=>"unique_protection_tag_rules_project_id_and_tag_name_pattern", :algorithm=>:concurrently})
main:    -> 0.0032s
main: -- execute("RESET statement_timeout")
main:    -> 0.0004s
main: == 20250715040428 UpdateContainerRegistryProtectionTagRulesPatternUniqueness: reverted (0.0543s)

main: == [advisory_lock_connection] object_id: 139200, pg_backend_pid: 83849

🥕 How to set up and validate locally

  1. Create a tag protection rule with a tag name pattern that does not exist yet.
ContainerRegistry::Protection::TagRule.create(project: Project.last, tag_name_pattern: "test")
# => #<ContainerRegistry::Protection::TagRule:0x000000016337bae0
# id: 54,
# project_id: 31,
# created_at: Tue, 15 Jul 2025 15:27:09.680955000 UTC +00:00,
# updated_at: Tue, 15 Jul 2025 15:27:09.680955000 UTC +00:00,
# minimum_access_level_for_push: nil,
# minimum_access_level_for_delete: nil,
# tag_name_pattern: "test">
  1. Try running the same command to create another immutable tag rule with the same tag name pattern.
ContainerRegistry::Protection::TagRule.create(project: Project.last, tag_name_pattern: "test").errors
# => #<ActiveModel::Errors [#<ActiveModel::Error attribute=tag_name_pattern, type=taken, options={:if=>:immutable?, :message=>"already taken by an immutable tag rule", :value=>"test"}>]>

It will result to an error with a message that the tag name pattern has already been taken.

  1. Creating another type of rule, mutable, with the same tag name pattern will succeed:
ContainerRegistry::Protection::TagRule.create(project: Project.last, tag_name_pattern: "test", minimum_access_level_for_push: :admin, minimum_access_level_for_delete: :admin)
# => #<ContainerRegistry::Protection::TagRule:0x0000000172efba50
# id: 55,
# project_id: 31,
# created_at: Tue, 15 Jul 2025 15:29:59.018748000 UTC +00:00,
# updated_at: Tue, 15 Jul 2025 15:29:59.018748000 UTC +00:00,
# minimum_access_level_for_push: "admin",
# minimum_access_level_for_delete: "admin",
# tag_name_pattern: "test">

You may try it again with a mutable tag rule first then an immutable one. Note to change the tag name pattern since they are unique per type :)

MR acceptance checklist

✔️ Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Related to #554776 (closed)

Edited by Adie (she/her)

Merge request reports

Loading