From 8e3f4d75a9a0db38e79df87eea6479e756fc1b02 Mon Sep 17 00:00:00 2001
From: "Dzmitry (Dima) Meshcharakou"
 <12459192-dmeshcharakou@users.noreply.gitlab.com>
Date: Wed, 12 Feb 2025 17:19:54 +0000
Subject: [PATCH] Change cleanup policies to ignore protected tags

---
 .../container_registry/protection/tag_rule.rb |  9 ++
 .../cleanup_tags_base_service.rb              | 23 +++++
 .../gitlab/cleanup_tags_service.rb            |  7 ++
 .../delete_container_repository_worker.rb     |  3 +-
 doc/api/container_registry.md                 |  1 +
 .../protection/tag_rule_spec.rb               | 64 +++++++++++++-
 .../gitlab/cleanup_tags_service_spec.rb       | 85 +++++++++++++++++--
 .../cleanup_tags_service_shared_examples.rb   | 22 +++++
 8 files changed, 202 insertions(+), 12 deletions(-)

diff --git a/app/models/container_registry/protection/tag_rule.rb b/app/models/container_registry/protection/tag_rule.rb
index 562cbbf2ac273..cd0be80788a04 100644
--- a/app/models/container_registry/protection/tag_rule.rb
+++ b/app/models/container_registry/protection/tag_rule.rb
@@ -7,6 +7,7 @@ class TagRule < ApplicationRecord
 
       ACCESS_LEVELS = Gitlab::Access.sym_options_with_admin.slice(:maintainer, :owner, :admin).freeze
       MAX_TAG_RULES_PER_PROJECT = 5
+      DELETE_ACTIONS = ['delete'].freeze
 
       enum :minimum_access_level_for_delete, ACCESS_LEVELS, prefix: true
       enum :minimum_access_level_for_push, ACCESS_LEVELS, prefix: true
@@ -26,6 +27,14 @@ class TagRule < ApplicationRecord
         where(conditions.reduce(:or))
       }
 
+      scope :for_delete_and_access, ->(access_level) do
+        for_actions_and_access(DELETE_ACTIONS, access_level)
+      end
+
+      scope :tag_name_patterns_for_project, ->(project_id) do
+        select(:tag_name_pattern).where(project_id: project_id)
+      end
+
       def push_restricted?(access_level)
         Gitlab::Access.sym_options_with_admin[minimum_access_level_for_push.to_sym] > access_level
       end
diff --git a/app/services/projects/container_repository/cleanup_tags_base_service.rb b/app/services/projects/container_repository/cleanup_tags_base_service.rb
index 45557d0350297..41cfbcc4a67e1 100644
--- a/app/services/projects/container_repository/cleanup_tags_base_service.rb
+++ b/app/services/projects/container_repository/cleanup_tags_base_service.rb
@@ -21,6 +21,29 @@ def filter_by_name!(tags)
         end
       end
 
+      def filter_out_protected!(tags)
+        return if Feature.disabled?(:container_registry_protected_tags, project)
+
+        tag_rules = ::ContainerRegistry::Protection::TagRule.tag_name_patterns_for_project(project.id)
+
+        if current_user
+          return if current_user.can_admin_all_resources?
+
+          user_access_level = current_user.max_member_access_for_project(project.id)
+          tag_rules = tag_rules.for_delete_and_access(user_access_level)
+        end
+
+        return if tag_rules.blank?
+
+        patterns = tag_rules.map { |rule| ::Gitlab::UntrustedRegexp.new(rule.tag_name_pattern) }
+
+        tags.reject! do |tag|
+          patterns.detect do |pattern|
+            pattern.match?(tag.name)
+          end
+        end
+      end
+
       # Should return [tags_to_delete, tags_to_keep]
       def partition_by_keep_n(tags)
         return [tags, []] unless keep_n
diff --git a/app/services/projects/container_repository/gitlab/cleanup_tags_service.rb b/app/services/projects/container_repository/gitlab/cleanup_tags_service.rb
index 714a9d4333342..6db66e4bc79c3 100644
--- a/app/services/projects/container_repository/gitlab/cleanup_tags_service.rb
+++ b/app/services/projects/container_repository/gitlab/cleanup_tags_service.rb
@@ -34,6 +34,9 @@ def execute_for_tags(tags, overall_result)
           tags = filter_by_keep_n(tags)
           tags = filter_by_older_than(tags)
 
+          # As a most expensive operation it should follow after the other filters.
+          filter_out_protected!(tags) unless skip_protected_tags?
+
           overall_result[:before_delete_size] += tags.size
           overall_result[:original_size] += original_size
 
@@ -76,6 +79,10 @@ def pushed_at(tag)
         def timeout_disabled?
           params['disable_timeout'] || false
         end
+
+        def skip_protected_tags?
+          !!params['skip_protected_tags']
+        end
       end
     end
   end
diff --git a/app/workers/container_registry/delete_container_repository_worker.rb b/app/workers/container_registry/delete_container_repository_worker.rb
index 5acd1a395a1dd..566158bee0dcb 100644
--- a/app/workers/container_registry/delete_container_repository_worker.rb
+++ b/app/workers/container_registry/delete_container_repository_worker.rb
@@ -19,7 +19,8 @@ class DeleteContainerRepositoryWorker
     CLEANUP_TAGS_SERVICE_PARAMS = {
       'name_regex_delete' => '.*',
       'keep_latest' => false,
-      'container_expiration_policy' => true # to avoid permissions checks
+      'container_expiration_policy' => true, # to avoid permissions checks
+      'skip_protected_tags' => true
     }.freeze
 
     def perform_work
diff --git a/doc/api/container_registry.md b/doc/api/container_registry.md
index c9522401d3226..3399a1e5d73ed 100644
--- a/doc/api/container_registry.md
+++ b/doc/api/container_registry.md
@@ -352,6 +352,7 @@ if successful, and performs the following operations:
 - It keeps N latest matching tags (if `keep_n` is specified).
 - It only removes tags that are older than X amount of time (if `older_than` is
   specified).
+- It excludes [protected tags](../user/packages/container_registry/protected_container_tags.md).
 - It schedules the asynchronous job to be executed in the background.
 
 These operations are executed asynchronously and can take time to get executed.
diff --git a/spec/models/container_registry/protection/tag_rule_spec.rb b/spec/models/container_registry/protection/tag_rule_spec.rb
index 3b40a16ac9db8..a34f39275f02c 100644
--- a/spec/models/container_registry/protection/tag_rule_spec.rb
+++ b/spec/models/container_registry/protection/tag_rule_spec.rb
@@ -3,6 +3,8 @@
 require 'spec_helper'
 
 RSpec.describe ContainerRegistry::Protection::TagRule, type: :model, feature_category: :container_registry do
+  using RSpec::Parameterized::TableSyntax
+
   it_behaves_like 'having unique enum values'
 
   describe 'relationships' do
@@ -141,8 +143,6 @@
       rule_four
     end
 
-    using RSpec::Parameterized::TableSyntax
-
     where(:user_access_level, :actions, :expected_rules) do
       Gitlab::Access::DEVELOPER  | ['push']         | lazy { [rule_one, rule_two, rule_three, rule_four] }
       Gitlab::Access::DEVELOPER  | ['delete']       | lazy { [rule_one, rule_two, rule_three, rule_four] }
@@ -169,6 +169,66 @@
     end
   end
 
+  describe '.for_delete_and_access' do
+    let_it_be(:rule_one) do
+      create(:container_registry_protection_tag_rule,
+        tag_name_pattern: 'one',
+        minimum_access_level_for_push: :maintainer,
+        minimum_access_level_for_delete: :maintainer)
+    end
+
+    let_it_be(:rule_two) do
+      create(:container_registry_protection_tag_rule,
+        tag_name_pattern: 'two',
+        minimum_access_level_for_push: :owner,
+        minimum_access_level_for_delete: :maintainer)
+    end
+
+    let_it_be(:rule_three) do
+      create(:container_registry_protection_tag_rule,
+        tag_name_pattern: 'three',
+        minimum_access_level_for_push: :maintainer,
+        minimum_access_level_for_delete: :admin)
+    end
+
+    let_it_be(:rule_four) do
+      create(:container_registry_protection_tag_rule,
+        tag_name_pattern: 'four',
+        minimum_access_level_for_push: :admin,
+        minimum_access_level_for_delete: :admin)
+    end
+
+    where(:user_access_level, :expected_rules) do
+      Gitlab::Access::DEVELOPER  | [ref(:rule_one), ref(:rule_two), ref(:rule_three), ref(:rule_four)]
+      Gitlab::Access::MAINTAINER | [ref(:rule_three), ref(:rule_four)]
+      Gitlab::Access::OWNER      | [ref(:rule_three), ref(:rule_four)]
+      Gitlab::Access::ADMIN      | []
+    end
+
+    with_them do
+      subject { described_class.for_delete_and_access(user_access_level) }
+
+      it 'returns the expected rules' do
+        is_expected.to match_array(expected_rules)
+      end
+    end
+  end
+
+  describe '.tag_name_patterns_for_projects' do
+    let_it_be(:rule) { create(:container_registry_protection_tag_rule) }
+    let_it_be(:rule2) { create(:container_registry_protection_tag_rule) }
+
+    subject(:result) { described_class.tag_name_patterns_for_project(rule.project_id) }
+
+    it 'contains matched rule' do
+      expect(result.pluck(:tag_name_pattern)).to contain_exactly(rule.tag_name_pattern)
+    end
+
+    it 'selects only the tag_name_pattern' do
+      expect(result.select_values).to contain_exactly(:tag_name_pattern)
+    end
+  end
+
   describe '#push_restricted?' do
     let(:rule) do
       create(
diff --git a/spec/services/projects/container_repository/gitlab/cleanup_tags_service_spec.rb b/spec/services/projects/container_repository/gitlab/cleanup_tags_service_spec.rb
index d2f8dcad1a4ef..e186795187f51 100644
--- a/spec/services/projects/container_repository/gitlab/cleanup_tags_service_spec.rb
+++ b/spec/services/projects/container_repository/gitlab/cleanup_tags_service_spec.rb
@@ -7,13 +7,12 @@
 
   include_context 'for a cleanup tags service'
 
-  let_it_be(:user) { create(:user) }
   let_it_be(:user) { create(:user) }
   let_it_be(:project, reload: true) { create(:project, :private) }
 
   let(:repository) { create(:container_repository, :root, project: project) }
   let(:service) { described_class.new(container_repository: repository, current_user: user, params: params) }
-  let(:tags) { %w[latest A Ba Bb C D E] }
+  let(:tags) { %w[latest A Ba Bb C D 17-8-stable] }
 
   before do
     project.add_maintainer(user) if user
@@ -37,7 +36,7 @@
         'Bb' => six_days_ago,
         'C' => one_month_ago,
         'D' => nil,
-        'E' => nil
+        '17-8-stable' => nil
       }
     )
   end
@@ -49,17 +48,17 @@
       let(:tags_page_size) { 2 }
 
       it_behaves_like 'when regex matching everything is specified',
-        delete_expectations: [%w[A], %w[Ba Bb], %w[C D], %w[E]]
+        delete_expectations: [%w[A], %w[Ba Bb], %w[C D], %w[17-8-stable]]
 
       it_behaves_like 'when regex matching everything is specified and latest is not kept',
-        delete_expectations: [%w[latest A], %w[Ba Bb], %w[C D], %w[E]]
+        delete_expectations: [%w[latest A], %w[Ba Bb], %w[C D], %w[17-8-stable]]
 
       it_behaves_like 'when delete regex matching specific tags is used'
 
       it_behaves_like 'when delete regex matching specific tags is used with overriding allow regex'
 
       it_behaves_like 'with allow regex value',
-        delete_expectations: [%w[A], %w[C D], %w[E]]
+        delete_expectations: [%w[A], %w[C D], %w[17-8-stable]]
 
       it_behaves_like 'when keeping only N tags',
         delete_expectations: [%w[Bb]]
@@ -87,6 +86,40 @@
       it_behaves_like 'when running a container_expiration_policy',
         delete_expectations: [%w[Bb], %w[C]]
 
+      it_behaves_like 'with protected rule having pattern ^\d{1,2}-\d{1,2}-stable$',
+        delete_expectations: [%w[A], %w[Ba Bb], %w[C D], %w[17-8-stable]]
+
+      context 'with admin minimum_access_level_for_delete' do
+        it_behaves_like 'with protected rule having pattern ^\d{1,2}-\d{1,2}-stable$',
+          delete_expectations: [%w[A], %w[Ba Bb], %w[C D]],
+          minimum_access_level_for_delete: :admin
+      end
+
+      context 'without user' do
+        let(:user) { nil }
+
+        it_behaves_like 'with protected rule having pattern ^\d{1,2}-\d{1,2}-stable$',
+          delete_expectations: [%w[A], %w[Ba Bb], %w[C D]]
+      end
+
+      context 'with the skip_protected_tags param' do
+        let(:params) do
+          { 'skip_protected_tags' => true }
+        end
+
+        it_behaves_like 'with protected rule having pattern ^\d{1,2}-\d{1,2}-stable$',
+          delete_expectations: [%w[A], %w[Ba Bb], %w[C D], %w[17-8-stable]]
+      end
+
+      context 'with the container_registry_protected_tags disabled' do
+        before do
+          stub_feature_flags(container_registry_protected_tags: false)
+        end
+
+        it_behaves_like 'with protected rule having pattern ^\d{1,2}-\d{1,2}-stable$',
+          delete_expectations: [%w[A], %w[Ba Bb], %w[C D], %w[17-8-stable]]
+      end
+
       context 'with a timeout' do
         let(:params) do
           { 'name_regex_delete' => '.*' }
@@ -113,7 +146,7 @@
           end
 
           it_behaves_like 'when regex matching everything is specified',
-            delete_expectations: [%w[A], %w[Ba Bb], %w[C D], %w[E]]
+            delete_expectations: [%w[A], %w[Ba Bb], %w[C D], %w[17-8-stable]]
         end
       end
     end
@@ -122,14 +155,14 @@
       let(:tags_page_size) { 1000 }
 
       it_behaves_like 'when regex matching everything is specified',
-        delete_expectations: [%w[A Ba Bb C D E]]
+        delete_expectations: [%w[A Ba Bb C D 17-8-stable]]
 
       it_behaves_like 'when delete regex matching specific tags is used'
 
       it_behaves_like 'when delete regex matching specific tags is used with overriding allow regex'
 
       it_behaves_like 'with allow regex value',
-        delete_expectations: [%w[A C D E]]
+        delete_expectations: [%w[A C D 17-8-stable]]
 
       it_behaves_like 'when keeping only N tags',
         delete_expectations: [%w[Ba Bb C]]
@@ -148,6 +181,40 @@
 
       it_behaves_like 'when running a container_expiration_policy',
         delete_expectations: [%w[Ba Bb C]]
+
+      it_behaves_like 'with protected rule having pattern ^\d{1,2}-\d{1,2}-stable$',
+        delete_expectations: [%w[A Ba Bb C D 17-8-stable]]
+
+      context 'with admin minimum_access_level_for_delete' do
+        it_behaves_like 'with protected rule having pattern ^\d{1,2}-\d{1,2}-stable$',
+          delete_expectations: [%w[A Ba Bb C D]],
+          minimum_access_level_for_delete: :admin
+      end
+
+      context 'without user' do
+        let(:user) { nil }
+
+        it_behaves_like 'with protected rule having pattern ^\d{1,2}-\d{1,2}-stable$',
+          delete_expectations: [%w[A Ba Bb C D]]
+      end
+
+      context 'with the skip_protected_tags param' do
+        let(:params) do
+          { 'skip_protected_tags' => true }
+        end
+
+        it_behaves_like 'with protected rule having pattern ^\d{1,2}-\d{1,2}-stable$',
+          delete_expectations: [%w[A Ba Bb C D 17-8-stable]]
+      end
+
+      context 'with the container_registry_protected_tags disabled' do
+        before do
+          stub_feature_flags(container_registry_protected_tags: false)
+        end
+
+        it_behaves_like 'with protected rule having pattern ^\d{1,2}-\d{1,2}-stable$',
+          delete_expectations: [%w[A Ba Bb C D 17-8-stable]]
+      end
     end
 
     context 'with no tags page' do
diff --git a/spec/support/shared_examples/projects/container_repository/cleanup_tags_service_shared_examples.rb b/spec/support/shared_examples/projects/container_repository/cleanup_tags_service_shared_examples.rb
index f9f8435c211ca..94b7fe3719572 100644
--- a/spec/support/shared_examples/projects/container_repository/cleanup_tags_service_shared_examples.rb
+++ b/spec/support/shared_examples/projects/container_repository/cleanup_tags_service_shared_examples.rb
@@ -194,6 +194,28 @@
   end
 end
 
+RSpec.shared_examples 'with protected rule having pattern ^\d{1,2}-\d{1,2}-stable$' do
+  |delete_expectations:, service_response_extra: {}, supports_caching: false,
+   minimum_access_level_for_delete: :maintainer|
+  let_it_be(:rule) do
+    create(
+      :container_registry_protection_tag_rule,
+      tag_name_pattern: '^\d{1,2}-\d{1,2}-stable$',
+      project: project,
+      minimum_access_level_for_delete: minimum_access_level_for_delete
+    )
+  end
+
+  let(:params) do
+    { 'name_regex_delete' => '.*' }
+  end
+
+  it_behaves_like 'removing the expected tags',
+    service_response_extra: service_response_extra,
+    supports_caching: supports_caching,
+    delete_expectations: delete_expectations
+end
+
 RSpec.shared_examples 'not removing anything' do |service_response_extra: {}, supports_caching: false|
   it 'does not remove anything' do
     expect(Projects::ContainerRepository::DeleteTagsService).not_to receive(:new)
-- 
GitLab