summaryrefslogtreecommitdiff
path: root/app/services/projects/destroy_service.rb
blob: afa8de04fcac6be348d3df43028a8dd144af5a7c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
# frozen_string_literal: true

module Projects
  class DestroyService < BaseService
    include Gitlab::ShellAdapter

    DestroyError = Class.new(StandardError)

    def async_execute
      project.update_attribute(:pending_delete, true)

      # Ensure no repository +deleted paths are kept,
      # regardless of any issue with the ProjectDestroyWorker
      # job process.
      schedule_stale_repos_removal

      job_id = ProjectDestroyWorker.perform_async(project.id, current_user.id, params)
      log_info("User #{current_user.id} scheduled destruction of project #{project.full_path} with job ID #{job_id}")
    end

    def execute
      return false unless can?(current_user, :remove_project, project)

      project.update_attribute(:pending_delete, true)
      # Flush the cache for both repositories. This has to be done _before_
      # removing the physical repositories as some expiration code depends on
      # Git data (e.g. a list of branch names).
      flush_caches(project)

      if Feature.enabled?(:abort_deleted_project_pipelines, default_enabled: :yaml)
        ::Ci::AbortPipelinesService.new.execute(project.all_pipelines, :project_deleted)
      end

      Projects::UnlinkForkService.new(project, current_user).execute

      attempt_destroy(project)

      system_hook_service.execute_hooks_for(project, :destroy)
      log_info("Project \"#{project.full_path}\" was deleted")

      current_user.invalidate_personal_projects_count

      true
    rescue StandardError => error
      attempt_rollback(project, error.message)
      false
    rescue Exception => error # rubocop:disable Lint/RescueException
      # Project.transaction can raise Exception
      attempt_rollback(project, error.message)
      raise
    end

    private

    def trash_project_repositories!
      unless remove_repository(project.repository)
        raise_error(s_('DeleteProject|Failed to remove project repository. Please try again or contact administrator.'))
      end

      unless remove_repository(project.wiki.repository)
        raise_error(s_('DeleteProject|Failed to remove wiki repository. Please try again or contact administrator.'))
      end
    end

    def trash_relation_repositories!
      unless remove_snippets
        raise_error(s_('DeleteProject|Failed to remove project snippets. Please try again or contact administrator.'))
      end
    end

    def remove_snippets
      response = ::Snippets::BulkDestroyService.new(current_user, project.snippets).execute

      response.success?
    end

    def remove_repository(repository)
      return true unless repository

      result = Repositories::DestroyService.new(repository).execute

      result[:status] == :success
    end

    def schedule_stale_repos_removal
      repos = [project.repository, project.wiki.repository]

      repos.each do |repository|
        next unless repository

        Repositories::ShellDestroyService.new(repository).execute(Repositories::ShellDestroyService::STALE_REMOVAL_DELAY)
      end
    end

    def attempt_rollback(project, message)
      return unless project

      # It's possible that the project was destroyed, but some after_commit
      # hook failed and caused us to end up here. A destroyed model will be a frozen hash,
      # which cannot be altered.
      project.update(delete_error: message, pending_delete: false) unless project.destroyed?

      log_error("Deletion failed on #{project.full_path} with the following message: #{message}")
    end

    def attempt_destroy(project)
      unless remove_registry_tags
        raise_error(s_('DeleteProject|Failed to remove some tags in project container registry. Please try again or contact administrator.'))
      end

      project.leave_pool_repository
      destroy_project_related_records(project)
    end

    def destroy_project_related_records(project)
      log_destroy_event
      trash_relation_repositories!
      trash_project_repositories!
      destroy_web_hooks!
      destroy_project_bots!

      # Rails attempts to load all related records into memory before
      # destroying: https://github.com/rails/rails/issues/22510
      # This ensures we delete records in batches.
      #
      # Exclude container repositories because its before_destroy would be
      # called multiple times, and it doesn't destroy any database records.
      project.destroy_dependent_associations_in_batches(exclude: [:container_repositories, :snippets])
      project.destroy!
    end

    def log_destroy_event
      log_info("Attempting to destroy #{project.full_path} (#{project.id})")
    end

    # The project can have multiple webhooks with hundreds of thousands of web_hook_logs.
    # By default, they are removed with "DELETE CASCADE" option defined via foreign_key.
    # But such queries can exceed the statement_timeout limit and fail to delete the project.
    # (see https://gitlab.com/gitlab-org/gitlab/-/issues/26259)
    #
    # To prevent that we use WebHooks::DestroyService. It deletes logs in batches and
    # produces smaller and faster queries to the database.
    def destroy_web_hooks!
      project.hooks.find_each do |web_hook|
        result = ::WebHooks::DestroyService.new(current_user).sync_destroy(web_hook)

        unless result[:status] == :success
          raise_error(s_('DeleteProject|Failed to remove webhooks. Please try again or contact administrator.'))
        end
      end
    end

    # The project can have multiple project bots with personal access tokens generated.
    # We need to remove them when a project is deleted
    # rubocop: disable CodeReuse/ActiveRecord
    def destroy_project_bots!
      project.members.includes(:user).references(:user).merge(User.project_bot).each do |member|
        Users::DestroyService.new(current_user).execute(member.user, skip_authorization: true)
      end
    end
    # rubocop: enable CodeReuse/ActiveRecord

    def remove_registry_tags
      return true unless Gitlab.config.registry.enabled
      return false unless remove_legacy_registry_tags

      project.container_repositories.find_each do |container_repository|
        service = Projects::ContainerRepository::DestroyService.new(project, current_user)
        service.execute(container_repository)
      end

      true
    end

    ##
    # This method makes sure that we correctly remove registry tags
    # for legacy image repository (when repository path equals project path).
    #
    def remove_legacy_registry_tags
      return true unless Gitlab.config.registry.enabled

      ::ContainerRepository.build_root_repository(project).tap do |repository|
        break repository.has_tags? ? repository.delete_tags! : true
      end
    end

    def raise_error(message)
      raise DestroyError, message
    end

    def flush_caches(project)
      Projects::ForksCountService.new(project).delete_cache
    end
  end
end

Projects::DestroyService.prepend_mod_with('Projects::DestroyService')