summaryrefslogtreecommitdiff
path: root/db/post_migrate/20200602143020_update_routes_for_lost_and_found_group_and_orphaned_projects.rb
blob: faa3c4161a0ce24770edf001f4a78d53ce94ef22 (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
# frozen_string_literal: true

# This migration adds or updates the routes for all the entities affected by
#  post-migration '20200511083541_cleanup_projects_with_missing_namespace'
# - A route is added for the 'lost-and-found' group
# - A route is added for the Ghost user (if not already defined)
# - The routes for all the orphaned projects that were moved under the 'lost-and-found'
#   group are updated to reflect the new path
class UpdateRoutesForLostAndFoundGroupAndOrphanedProjects < ActiveRecord::Migration[6.0]
  DOWNTIME = false

  class User < ActiveRecord::Base
    self.table_name = 'users'

    LOST_AND_FOUND_GROUP = 'lost-and-found'
    USER_TYPE_GHOST = 5
    ACCESS_LEVEL_OWNER = 50

    has_one :namespace, -> { where(type: nil) },
            foreign_key: :owner_id, inverse_of: :owner, autosave: true,
            class_name: 'UpdateRoutesForLostAndFoundGroupAndOrphanedProjects::Namespace'

    def lost_and_found_group
      # Find the 'lost-and-found' group
      # There should only be one Group owned by the Ghost user starting with 'lost-and-found'
      Group
        .joins('INNER JOIN members ON namespaces.id = members.source_id')
        .where('namespaces.type = ?', 'Group')
        .where('members.type = ?', 'GroupMember')
        .where('members.source_type = ?', 'Namespace')
        .where('members.user_id = ?', self.id)
        .where('members.access_level = ?', ACCESS_LEVEL_OWNER)
        .find_by(Group.arel_table[:name].matches("#{LOST_AND_FOUND_GROUP}%"))
    end

    class << self
      # Return the ghost user
      def ghost
        User.find_by(user_type: USER_TYPE_GHOST)
      end
    end
  end

  # Temporary Concern to not repeat the same methods twice
  module HasPath
    extend ActiveSupport::Concern

    def full_path
      if parent && path
        parent.full_path + '/' + path
      else
        path
      end
    end

    def full_name
      if parent && name
        parent.full_name + ' / ' + name
      else
        name
      end
    end
  end

  class Namespace < ActiveRecord::Base
    include HasPath

    self.table_name = 'namespaces'

    belongs_to :owner, class_name: 'UpdateRoutesForLostAndFoundGroupAndOrphanedProjects::User'
    belongs_to :parent, class_name: "UpdateRoutesForLostAndFoundGroupAndOrphanedProjects::Namespace"
    has_many :children, foreign_key: :parent_id,
             class_name: "UpdateRoutesForLostAndFoundGroupAndOrphanedProjects::Namespace"
    has_many :projects, class_name: "UpdateRoutesForLostAndFoundGroupAndOrphanedProjects::Project"

    def ensure_route!
      unless Route.for_source('Namespace', self.id)
        Route.create!(
          source_id: self.id,
          source_type: 'Namespace',
          path: self.full_path,
          name: self.full_name
        )
      end
    end

    def generate_unique_path
      # Generate a unique path if there is no route for the namespace
      # (an existing route guarantees that the path is already unique)
      unless Route.for_source('Namespace', self.id)
        self.path = Uniquify.new.string(self.path) do |str|
          Route.where(path: str).exists?
        end
      end
    end
  end

  class Group < Namespace
    # Disable STI to allow us to manually set "type = 'Group'"
    # Otherwise rails forces "type = CleanupProjectsWithMissingNamespace::Group"
    self.inheritance_column = :_type_disabled
  end

  class Route < ActiveRecord::Base
    self.table_name = 'routes'

    def self.for_source(source_type, source_id)
      Route.find_by(source_type: source_type, source_id: source_id)
    end
  end

  class Project < ActiveRecord::Base
    include HasPath

    self.table_name = 'projects'

    belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id',
      class_name: "UpdateRoutesForLostAndFoundGroupAndOrphanedProjects::Group"
    belongs_to :namespace,
      class_name: "UpdateRoutesForLostAndFoundGroupAndOrphanedProjects::Namespace"

    alias_method :parent, :namespace
    alias_attribute :parent_id, :namespace_id

    def ensure_route!
      Route.find_or_initialize_by(source_type: 'Project', source_id: self.id).tap do |record|
        record.path = self.full_path
        record.name = self.full_name
        record.save!
      end
    end
  end

  def up
    # Reset the column information of all the models that update the database
    # to ensure the Active Record's knowledge of the table structure is current
    Namespace.reset_column_information
    Route.reset_column_information
    User.reset_column_information

    # Find the ghost user, its namespace and the "lost and found" group
    ghost_user = User.ghost
    return unless ghost_user # No reason to continue if there is no Ghost user

    ghost_namespace = ghost_user.namespace
    lost_and_found_group = ghost_user.lost_and_found_group

    # No reason to continue if there is no 'lost-and-found' group
    # 1. No orphaned projects were found in this instance, or
    # 2. The 'lost-and-found' group and the orphaned projects have been already deleted
    return unless lost_and_found_group

    # Update the 'lost-and-found' group description to be more self-explanatory
    lost_and_found_group.generate_unique_path
    lost_and_found_group.description =
      'Group for storing projects that were not properly deleted. '\
      'It should be considered as a system level Group with non-working '\
      'projects inside it. The contents may be deleted with a future update. '\
      'More info: gitlab.com/gitlab-org/gitlab/-/issues/198603'
    lost_and_found_group.save!

    # make sure that the ghost namespace has a unique path
    ghost_namespace.generate_unique_path

    if ghost_namespace.path_changed?
      ghost_namespace.save!
      # If the path changed, also update the Ghost User's username to match the new path.
      ghost_user.update!(username: ghost_namespace.path)
    end

    # Update the routes for the Ghost user, the "lost and found" group
    # and all the orphaned projects
    ghost_namespace.ensure_route!
    lost_and_found_group.ensure_route!

    # The following does a fast index scan by namespace_id
    # No reason to process in batches:
    # - 66 projects in GitLab.com, less than 1ms execution time to fetch them
    #   with a constant update time for each
    lost_and_found_group.projects.each do |project|
      project.ensure_route!
    end
  end

  def down
    # no-op
  end
end