diff options
-rw-r--r-- | .gitlab-ci.yml | 3 | ||||
-rw-r--r-- | doc/development/migration_style_guide.md | 46 | ||||
-rw-r--r-- | generator_templates/active_record/migration/create_table_migration.rb | 8 | ||||
-rw-r--r-- | generator_templates/active_record/migration/migration.rb | 8 | ||||
-rw-r--r-- | lib/gitlab/downtime_check.rb | 71 | ||||
-rw-r--r-- | lib/gitlab/downtime_check/message.rb | 28 | ||||
-rw-r--r-- | lib/tasks/downtime_check.rake | 26 | ||||
-rw-r--r-- | lib/tasks/gitlab/db.rake | 15 | ||||
-rw-r--r-- | spec/lib/gitlab/downtime_check/message_spec.rb | 17 | ||||
-rw-r--r-- | spec/lib/gitlab/downtime_check_spec.rb | 113 |
10 files changed, 311 insertions, 24 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ff8aa351226..f566dfd76e9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -148,7 +148,7 @@ spinach 9 10: *spinach-knapsack .spinach-knapsack-ruby23: &spinach-knapsack-ruby23 <<: *spinach-knapsack <<: *ruby-23 - + rspec 0 20 ruby23: *rspec-knapsack-ruby23 rspec 1 20 ruby23: *rspec-knapsack-ruby23 rspec 2 20 ruby23: *rspec-knapsack-ruby23 @@ -196,6 +196,7 @@ rake flog: *exec rake flay: *exec rake db:migrate:reset: *exec license_finder: *exec +rake downtime_check: *exec bundler:audit: stage: test diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md index e2ca46504e7..b8fab3aaff7 100644 --- a/doc/development/migration_style_guide.md +++ b/doc/development/migration_style_guide.md @@ -11,7 +11,8 @@ migrations are written carefully, can be applied online and adhere to the style Migrations should not require GitLab installations to be taken offline unless _absolutely_ necessary. If a migration requires downtime this should be clearly mentioned during the review process as well as being documented in the -monthly release post. +monthly release post. For more information see the "Downtime Tagging" section +below. When writing your migrations, also consider that databases might have stale data or inconsistencies and guard for that. Try to make as little assumptions as possible @@ -20,35 +21,34 @@ about the state of the database. Please don't depend on GitLab specific code since it can change in future versions. If needed copy-paste GitLab code into the migration to make it forward compatible. -## Comments in the migration +## Downtime Tagging -Each migration you write needs to have the two following pieces of information -as comments. +Every migration must specify if it requires downtime or not, and if it should +require downtime it must also specify a reason for this. To do so, add the +following two constants to the migration class' body: -### Online, Offline, errors? +* `DOWNTIME`: a boolean that when set to `true` indicates the migration requires + downtime. +* `DOWNTIME_REASON`: a String containing the reason for the migration requiring + downtime. This constant **must** be set when `DOWNTIME` is set to `true`. -First, you need to provide information on whether the migration can be applied: +For example: -1. online without errors (works on previous version and new one) -2. online with errors on old instances after migrating -3. online with errors on new instances while migrating -4. offline (needs to happen without app servers to prevent db corruption) - -For example: - -``` -# Migration type: online without errors (works on previous version and new one) +```ruby class MyMigration < ActiveRecord::Migration -... -``` + DOWNTIME = true + DOWNTIME_REASON = 'This migration requires downtime because ...' -It is always preferable to have a migration run online. If you expect the migration -to take particularly long (for instance, if it loops through all notes), -this is valuable information to add. + def change + ... + end +end +``` -If you don't provide the information it means that a migration is safe to run online. +It is an error (that is, CI will fail) if the `DOWNTIME` constant is missing +from a migration class. -### Reversibility +## Reversibility Your migration should be reversible. This is very important, as it should be possible to downgrade in case of a vulnerability or bugs. @@ -100,7 +100,7 @@ value of `10` you'd write the following: class MyMigration < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers disable_ddl_transaction! - + def up add_column_with_default(:projects, :foo, :integer, default: 10) end diff --git a/generator_templates/active_record/migration/create_table_migration.rb b/generator_templates/active_record/migration/create_table_migration.rb index 27acc75dcc4..aad8626a720 100644 --- a/generator_templates/active_record/migration/create_table_migration.rb +++ b/generator_templates/active_record/migration/create_table_migration.rb @@ -4,6 +4,14 @@ class <%= migration_class_name %> < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + # When using the methods "add_concurrent_index" or "add_column_with_default" # you must disable the use of transactions as these methods can not run in an # existing transaction. When using "add_concurrent_index" make sure that this diff --git a/generator_templates/active_record/migration/migration.rb b/generator_templates/active_record/migration/migration.rb index 06bdea11367..825bc8bdf61 100644 --- a/generator_templates/active_record/migration/migration.rb +++ b/generator_templates/active_record/migration/migration.rb @@ -4,6 +4,14 @@ class <%= migration_class_name %> < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + # When using the methods "add_concurrent_index" or "add_column_with_default" # you must disable the use of transactions as these methods can not run in an # existing transaction. When using "add_concurrent_index" make sure that this diff --git a/lib/gitlab/downtime_check.rb b/lib/gitlab/downtime_check.rb new file mode 100644 index 00000000000..ab9537ed7d7 --- /dev/null +++ b/lib/gitlab/downtime_check.rb @@ -0,0 +1,71 @@ +module Gitlab + # Checks if a set of migrations requires downtime or not. + class DowntimeCheck + # The constant containing the boolean that indicates if downtime is needed + # or not. + DOWNTIME_CONST = :DOWNTIME + + # The constant that specifies the reason for the migration requiring + # downtime. + DOWNTIME_REASON_CONST = :DOWNTIME_REASON + + # Checks the given migration paths and returns an Array of + # `Gitlab::DowntimeCheck::Message` instances. + # + # migrations - The migration file paths to check. + def check(migrations) + migrations.map do |path| + require(path) + + migration_class = class_for_migration_file(path) + + unless migration_class.const_defined?(DOWNTIME_CONST) + raise "The migration in #{path} does not specify if it requires " \ + "downtime or not" + end + + if online?(migration_class) + Message.new(path) + else + reason = downtime_reason(migration_class) + + unless reason + raise "The migration in #{path} requires downtime but no reason " \ + "was given" + end + + Message.new(path, true, reason) + end + end + end + + # Checks the given migrations and prints the results to STDOUT/STDERR. + # + # migrations - The migration file paths to check. + def check_and_print(migrations) + check(migrations).each do |message| + puts message.to_s # rubocop: disable Rails/Output + end + end + + # Returns the class for the given migration file path. + def class_for_migration_file(path) + File.basename(path, File.extname(path)).split('_', 2).last.camelize. + constantize + end + + # Returns true if the given migration can be performed without downtime. + def online?(migration) + migration.const_get(DOWNTIME_CONST) == false + end + + # Returns the downtime reason, or nil if none was defined. + def downtime_reason(migration) + if migration.const_defined?(DOWNTIME_REASON_CONST) + migration.const_get(DOWNTIME_REASON_CONST) + else + nil + end + end + end +end diff --git a/lib/gitlab/downtime_check/message.rb b/lib/gitlab/downtime_check/message.rb new file mode 100644 index 00000000000..4446e921e0d --- /dev/null +++ b/lib/gitlab/downtime_check/message.rb @@ -0,0 +1,28 @@ +module Gitlab + class DowntimeCheck + class Message + attr_reader :path, :offline, :reason + + OFFLINE = "\e[32moffline\e[0m" + ONLINE = "\e[31monline\e[0m" + + # path - The file path of the migration. + # offline - When set to `true` the migration will require downtime. + # reason - The reason as to why the migration requires downtime. + def initialize(path, offline = false, reason = nil) + @path = path + @offline = offline + @reason = reason + end + + def to_s + label = offline ? OFFLINE : ONLINE + + message = "[#{label}]: #{path}" + message += ": #{reason}" if reason + + message + end + end + end +end diff --git a/lib/tasks/downtime_check.rake b/lib/tasks/downtime_check.rake new file mode 100644 index 00000000000..30a2e9be5ce --- /dev/null +++ b/lib/tasks/downtime_check.rake @@ -0,0 +1,26 @@ +desc 'Checks if migrations in a branch require downtime' +task downtime_check: :environment do + # First we'll want to make sure we're comparing with the right upstream + # repository/branch. + current_branch = `git rev-parse --abbrev-ref HEAD`.strip + + # Either the developer ran this task directly on the master branch, or they're + # making changes directly on the master branch. + if current_branch == 'master' + if defined?(Gitlab::License) + repo = 'gitlab-ee' + else + repo = 'gitlab-ce' + end + + `git fetch https://gitlab.com/gitlab-org/#{repo}.git --depth 1` + + compare_with = 'FETCH_HEAD' + # The developer is working on a different branch, in this case we can just + # compare with the master branch. + else + compare_with = 'master' + end + + Rake::Task['gitlab:db:downtime_check'].invoke(compare_with) +end diff --git a/lib/tasks/gitlab/db.rake b/lib/tasks/gitlab/db.rake index 7230b9485be..0ec19e1a625 100644 --- a/lib/tasks/gitlab/db.rake +++ b/lib/tasks/gitlab/db.rake @@ -46,5 +46,20 @@ namespace :gitlab do Rake::Task['db:seed_fu'].invoke end end + + desc 'Checks if migrations require downtime or not' + task :downtime_check, [:ref] => :environment do |_, args| + abort 'You must specify a Git reference to compare with' unless args[:ref] + + require 'shellwords' + + ref = Shellwords.escape(args[:ref]) + + migrations = `git diff #{ref}.. --name-only -- db/migrate`.lines. + map { |file| Rails.root.join(file.strip).to_s }. + select { |file| File.file?(file) } + + Gitlab::DowntimeCheck.new.check_and_print(migrations) + end end end diff --git a/spec/lib/gitlab/downtime_check/message_spec.rb b/spec/lib/gitlab/downtime_check/message_spec.rb new file mode 100644 index 00000000000..93094cda776 --- /dev/null +++ b/spec/lib/gitlab/downtime_check/message_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' + +describe Gitlab::DowntimeCheck::Message do + describe '#to_s' do + it 'returns an ANSI formatted String for an offline migration' do + message = described_class.new('foo.rb', true, 'hello') + + expect(message.to_s).to eq("[\e[32moffline\e[0m]: foo.rb: hello") + end + + it 'returns an ANSI formatted String for an online migration' do + message = described_class.new('foo.rb') + + expect(message.to_s).to eq("[\e[31monline\e[0m]: foo.rb") + end + end +end diff --git a/spec/lib/gitlab/downtime_check_spec.rb b/spec/lib/gitlab/downtime_check_spec.rb new file mode 100644 index 00000000000..42d895e548e --- /dev/null +++ b/spec/lib/gitlab/downtime_check_spec.rb @@ -0,0 +1,113 @@ +require 'spec_helper' + +describe Gitlab::DowntimeCheck do + subject { described_class.new } + let(:path) { 'foo.rb' } + + describe '#check' do + before do + expect(subject).to receive(:require).with(path) + end + + context 'when a migration does not specify if downtime is required' do + it 'raises RuntimeError' do + expect(subject).to receive(:class_for_migration_file). + with(path). + and_return(Class.new) + + expect { subject.check([path]) }. + to raise_error(RuntimeError, /it requires downtime/) + end + end + + context 'when a migration requires downtime' do + context 'when no reason is specified' do + it 'raises RuntimeError' do + stub_const('TestMigration::DOWNTIME', true) + + expect(subject).to receive(:class_for_migration_file). + with(path). + and_return(TestMigration) + + expect { subject.check([path]) }. + to raise_error(RuntimeError, /no reason was given/) + end + end + + context 'when a reason is specified' do + it 'returns an Array of messages' do + stub_const('TestMigration::DOWNTIME', true) + stub_const('TestMigration::DOWNTIME_REASON', 'foo') + + expect(subject).to receive(:class_for_migration_file). + with(path). + and_return(TestMigration) + + messages = subject.check([path]) + + expect(messages).to be_an_instance_of(Array) + expect(messages[0]).to be_an_instance_of(Gitlab::DowntimeCheck::Message) + + message = messages[0] + + expect(message.path).to eq(path) + expect(message.offline).to eq(true) + expect(message.reason).to eq('foo') + end + end + end + end + + describe '#check_and_print' do + it 'checks the migrations and prints the results to STDOUT' do + stub_const('TestMigration::DOWNTIME', true) + stub_const('TestMigration::DOWNTIME_REASON', 'foo') + + expect(subject).to receive(:require).with(path) + + expect(subject).to receive(:class_for_migration_file). + with(path). + and_return(TestMigration) + + expect(subject).to receive(:puts).with(an_instance_of(String)) + + subject.check_and_print([path]) + end + end + + describe '#class_for_migration_file' do + it 'returns the class for a migration file path' do + expect(subject.class_for_migration_file('123_string.rb')).to eq(String) + end + end + + describe '#online?' do + it 'returns true when a migration can be performed online' do + stub_const('TestMigration::DOWNTIME', false) + + expect(subject.online?(TestMigration)).to eq(true) + end + + it 'returns false when a migration can not be performed online' do + stub_const('TestMigration::DOWNTIME', true) + + expect(subject.online?(TestMigration)).to eq(false) + end + end + + describe '#downtime_reason' do + context 'when a reason is defined' do + it 'returns the downtime reason' do + stub_const('TestMigration::DOWNTIME_REASON', 'hello') + + expect(subject.downtime_reason(TestMigration)).to eq('hello') + end + end + + context 'when a reason is not defined' do + it 'returns nil' do + expect(subject.downtime_reason(Class.new)).to be_nil + end + end + end +end |