File: migration_schema_validator.rb

package info (click to toggle)
gitlab 17.6.5-19
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 629,368 kB
  • sloc: ruby: 1,915,304; javascript: 557,307; sql: 60,639; xml: 6,509; sh: 4,567; makefile: 1,239; python: 406
file content (131 lines) | stat: -rw-r--r-- 3,691 bytes parent folder | download
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
# frozen_string_literal: true

require 'open3'

class MigrationSchemaValidator
  FILENAME = 'db/structure.sql'

  MIGRATION_DIRS = %w[db/migrate db/post_migrate].freeze

  SCHEMA_VERSION_DIR = 'db/schema_migrations'

  VERSION_DIGITS = 14

  SKIP_VALIDATION_LABEL = 'pipeline:skip-check-migrations'

  def validate!
    if committed_migrations.empty?
      puts "\e[32m No migrations found, skipping schema validation\e[0m"
      return
    end

    if skip_validation?
      puts "\e[32m Label #{SKIP_VALIDATION_LABEL} is present, skipping schema validation\e[0m"
      return
    end

    validate_schema_on_rollback!
    validate_schema_on_migrate!
    validate_schema_version_files!
  end

  private

  def skip_validation?
    ENV.fetch('CI_MERGE_REQUEST_LABELS', '').split(',').include?(SKIP_VALIDATION_LABEL)
  end

  def validate_schema_on_rollback!
    committed_migrations.reverse_each do |filename|
      version = find_migration_version(filename)

      run("scripts/db_tasks db:migrate:down VERSION=#{version}")
      run("scripts/db_tasks db:schema:dump")
    end

    git_command = "git diff #{diff_target} -- #{FILENAME}"
    base_message = "rollback of added migrations does not revert #{FILENAME} to previous state, " \
      "please investigate. Apply the '#{SKIP_VALIDATION_LABEL}' label to skip this check if needed." \
      "If you are unsure why this job is failing for your MR, then please refer to this page: " \
      "https://docs.gitlab.com/ee/development/database/dbcheck-migrations-job.html#false-positives"

    validate_clean_output!(git_command, base_message)
  end

  def validate_schema_on_migrate!
    run("scripts/db_tasks db:migrate")
    run("scripts/db_tasks db:schema:dump")

    git_command = "git diff -- #{FILENAME}"
    base_message = "the committed #{FILENAME} does not match the one generated by running added migrations"

    validate_clean_output!(git_command, base_message)
  end

  def validate_schema_version_files!
    git_command = "git add -A -n #{SCHEMA_VERSION_DIR}"
    base_message = "the committed files in #{SCHEMA_VERSION_DIR} do not match those expected by the added migrations"

    validate_clean_output!(git_command, base_message)
  end

  def committed_migrations
    @committed_migrations ||= begin
      git_command = "git diff --name-only --diff-filter=A #{diff_target} -- #{MIGRATION_DIRS.join(' ')}"

      run(git_command).split("\n")
    end
  end

  def diff_target
    @diff_target ||= pipeline_for_merged_results? ? target_branch : merge_base
  end

  def merge_base
    run("git merge-base #{target_branch} #{source_ref}")
  end

  def target_branch
    ENV['CI_MERGE_REQUEST_TARGET_BRANCH_NAME'] || ENV['TARGET'] || ENV['CI_DEFAULT_BRANCH'] || 'master'
  end

  def source_ref
    ENV['CI_COMMIT_SHA'] || 'HEAD'
  end

  def pipeline_for_merged_results?
    ENV.key?('CI_MERGE_REQUEST_SOURCE_BRANCH_SHA')
  end

  def find_migration_version(filename)
    file_basename = File.basename(filename)
    version_match = /\A(?<version>\d{#{VERSION_DIGITS}})_/o.match(file_basename)

    die "#{filename} has an invalid migration version" if version_match.nil?

    version_match[:version]
  end

  def validate_clean_output!(command, base_message)
    command_output = run(command)

    return if command_output.empty?

    die "#{base_message}:\n#{command_output}"
  end

  def die(message, error_code: 1)
    puts "\e[31mError: #{message}\e[0m"
    exit error_code
  end

  def run(cmd)
    puts "\e[32m$ #{cmd}\e[37m"
    stdout_str, stderr_str, status = Open3.capture3(cmd)
    puts "#{stdout_str}#{stderr_str}\e[0m"

    die "command failed: #{stderr_str}" unless status.success?

    stdout_str.chomp
  end
end