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
|