departure-76c9880 6.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +8 -0
  3. data/.gitignore +12 -0
  4. data/.rspec +2 -0
  5. data/.rubocop.yml +60 -0
  6. data/.travis.yml +30 -0
  7. data/CHANGELOG.md +192 -0
  8. data/CODE_OF_CONDUCT.md +50 -0
  9. data/Dockerfile +32 -0
  10. data/Gemfile +6 -0
  11. data/LICENSE.txt +22 -0
  12. data/README.md +227 -0
  13. data/RELEASING.md +17 -0
  14. data/Rakefile +17 -0
  15. data/bin/console +14 -0
  16. data/bin/rspec +16 -0
  17. data/bin/setup +7 -0
  18. data/config.yml.erb +4 -0
  19. data/configuration.rb +16 -0
  20. data/departure.gemspec +35 -0
  21. data/docker-compose.yml +23 -0
  22. data/lib/active_record/connection_adapters/for_alter.rb +91 -0
  23. data/lib/active_record/connection_adapters/percona_adapter.rb +168 -0
  24. data/lib/departure.rb +43 -0
  25. data/lib/departure/alter_argument.rb +49 -0
  26. data/lib/departure/cli_generator.rb +84 -0
  27. data/lib/departure/command.rb +96 -0
  28. data/lib/departure/configuration.rb +20 -0
  29. data/lib/departure/connection_base.rb +9 -0
  30. data/lib/departure/connection_details.rb +96 -0
  31. data/lib/departure/dsn.rb +24 -0
  32. data/lib/departure/errors.rb +39 -0
  33. data/lib/departure/log_sanitizers/password_sanitizer.rb +22 -0
  34. data/lib/departure/logger.rb +42 -0
  35. data/lib/departure/logger_factory.rb +16 -0
  36. data/lib/departure/migration.rb +96 -0
  37. data/lib/departure/null_logger.rb +15 -0
  38. data/lib/departure/option.rb +75 -0
  39. data/lib/departure/railtie.rb +21 -0
  40. data/lib/departure/runner.rb +62 -0
  41. data/lib/departure/user_options.rb +44 -0
  42. data/lib/departure/version.rb +3 -0
  43. data/lib/lhm.rb +23 -0
  44. data/lib/lhm/adapter.rb +107 -0
  45. data/lib/lhm/column_with_sql.rb +96 -0
  46. data/lib/lhm/column_with_type.rb +29 -0
  47. data/test_database.rb +80 -0
  48. metadata +245 -0
@@ -0,0 +1,49 @@
1
+ module Departure
2
+ class InvalidAlterStatement < StandardError; end
3
+
4
+ # Represents the '--alter' argument of Percona's pt-online-schema-change
5
+ # See https://www.percona.com/doc/percona-toolkit/2.0/pt-online-schema-change.html
6
+ class AlterArgument
7
+ ALTER_TABLE_REGEX = /\AALTER TABLE [^\s]*[\n]* /
8
+
9
+ attr_reader :table_name
10
+
11
+ # Constructor
12
+ #
13
+ # @param statement [String]
14
+ # @raise [InvalidAlterStatement] if the statement is not an ALTER TABLE
15
+ def initialize(statement)
16
+ @statement = statement
17
+
18
+ match = statement.match(ALTER_TABLE_REGEX)
19
+ raise InvalidAlterStatement unless match
20
+ # Separates the ALTER TABLE from the table_name
21
+ #
22
+ # Removes the grave marks, if they are there, so we can get the table_name
23
+ @table_name = String(match)
24
+ .split(' ')[2]
25
+ .delete('`')
26
+ end
27
+
28
+ # Returns the '--alter' pt-online-schema-change argument as a string. See
29
+ # https://www.percona.com/doc/percona-toolkit/2.0/pt-online-schema-change.html
30
+ def to_s
31
+ "--alter \"#{parsed_statement}\""
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :statement
37
+
38
+ # Removes the 'ALTER TABLE' portion of the SQL statement
39
+ #
40
+ # @return [String]
41
+ def parsed_statement
42
+ @parsed_statement ||= statement
43
+ .gsub(ALTER_TABLE_REGEX, '')
44
+ .gsub('`', '\\\`')
45
+ .gsub(/\\n/, '')
46
+ .gsub('"', '\\\"')
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,84 @@
1
+ require 'departure/dsn'
2
+ require 'departure/option'
3
+ require 'departure/alter_argument'
4
+ require 'departure/connection_details'
5
+ require 'departure/user_options'
6
+
7
+ module Departure
8
+ # Generates the equivalent Percona's pt-online-schema-change command to the
9
+ # given SQL statement
10
+ #
11
+ # --no-check-alter is used to allow running CHANGE COLUMN statements. For more details, check:
12
+ # www.percona.com/doc/percona-toolkit/2.2/pt-online-schema-change.html#cmdoption-pt-online-schema-change--[no]check-alter # rubocop:disable Metrics/LineLength
13
+ #
14
+ class CliGenerator
15
+ COMMAND_NAME = 'pt-online-schema-change'.freeze
16
+ DEFAULT_OPTIONS = Set.new(
17
+ [
18
+ Option.new('execute'),
19
+ Option.new('statistics'),
20
+ Option.new('alter-foreign-keys-method', 'auto'),
21
+ Option.new('no-check-alter')
22
+ ]
23
+ ).freeze
24
+
25
+ # TODO: Better doc.
26
+ #
27
+ # Constructor. Specify any arguments to pass to pt-online-schema-change
28
+ # passing the PERCONA_ARGS env var when executing the migration
29
+ #
30
+ # @param connection_data [Hash]
31
+ def initialize(connection_details)
32
+ @connection_details = connection_details
33
+ end
34
+
35
+ # Generates the percona command. Fills all the connection credentials from
36
+ # the current AR connection, but that can be amended via ENV-vars:
37
+ # PERCONA_DB_HOST, PERCONA_DB_USER, PERCONA_DB_PASSWORD, PERCONA_DB_NAME
38
+ # Table name can't not be amended, it populates automatically from the
39
+ # migration data
40
+ #
41
+ # @param table_name [String]
42
+ # @param statement [String] MySQL statement
43
+ # @return [String]
44
+ def generate(table_name, statement)
45
+ alter_argument = AlterArgument.new(statement)
46
+ dsn = DSN.new(connection_details.database, table_name)
47
+
48
+ "#{command} #{all_options} #{dsn} #{alter_argument}"
49
+ end
50
+
51
+ # Generates the percona command for a raw MySQL statement. Fills all the
52
+ # connection credentials from the current AR connection, but that can
53
+ # amended via ENV-vars: PERCONA_DB_HOST, PERCONA_DB_USER,
54
+ # PERCONA_DB_PASSWORD, PERCONA_DB_NAME Table name can't not be amended, it
55
+ # populates automatically from the migration data
56
+ #
57
+ # @param statement [String] MySQL statement
58
+ # @return [String]
59
+ def parse_statement(statement)
60
+ alter_argument = AlterArgument.new(statement)
61
+ dsn = DSN.new(connection_details.database, alter_argument.table_name)
62
+
63
+ "#{command} #{all_options} #{dsn} #{alter_argument}"
64
+ end
65
+
66
+ private
67
+
68
+ attr_reader :connection_details
69
+
70
+ def command
71
+ "#{COMMAND_NAME} #{connection_details}"
72
+ end
73
+
74
+ # Returns all the arguments to execute pt-online-schema-change with
75
+ #
76
+ # @return [String]
77
+ def all_options
78
+ env_variable_options = UserOptions.new
79
+ global_configuration_options = UserOptions.new(Departure.configuration.global_percona_args)
80
+ options = env_variable_options.merge(global_configuration_options).merge(DEFAULT_OPTIONS)
81
+ options.to_a.join(' ')
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,96 @@
1
+ module Departure
2
+ # Executes the given command returning it's status and errors
3
+ class Command
4
+ COMMAND_NOT_FOUND = 127
5
+
6
+ # Constructor
7
+ #
8
+ # @param command_line [String]
9
+ # @param error_log_path [String]
10
+ # @param logger [#write_no_newline]
11
+ def initialize(command_line, error_log_path, logger)
12
+ @command_line = command_line
13
+ @error_log_path = error_log_path
14
+ @logger = logger
15
+ end
16
+
17
+ # Executes the command returning its status. It also prints its stdout to
18
+ # the logger and its stderr to the file specified in error_log_path.
19
+ #
20
+ # @raise [NoStatusError] if the spawned process' status can't be retrieved
21
+ # @raise [SignalError] if the spawned process received a signal
22
+ # @raise [CommandNotFoundError] if pt-online-schema-change can't be found
23
+ #
24
+ # @return [Process::Status]
25
+ def run
26
+ log_started
27
+
28
+ run_in_process
29
+
30
+ log_finished
31
+
32
+ validate_status!
33
+ status
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :command_line, :error_log_path, :logger, :status
39
+
40
+ # Runs the command in a separate process, capturing its stdout and
41
+ # execution status
42
+ def run_in_process
43
+ Open3.popen3(full_command) do |_stdin, stdout, _stderr, waith_thr|
44
+ begin
45
+ loop do
46
+ IO.select([stdout])
47
+ data = stdout.read_nonblock(8192)
48
+ logger.write_no_newline(data)
49
+ end
50
+ rescue EOFError # rubocop:disable Lint/HandleExceptions
51
+ # noop
52
+ ensure
53
+ @status = waith_thr.value
54
+ end
55
+ end
56
+ end
57
+
58
+ # Builds the actual command including stderr redirection to the specified
59
+ # log file
60
+ #
61
+ # @return [String]
62
+ def full_command
63
+ "#{command_line} 2> #{error_log_path}"
64
+ end
65
+
66
+ # Validates the status of the execution
67
+ #
68
+ # @raise [NoStatusError] if the spawned process' status can't be retrieved
69
+ # @raise [SignalError] if the spawned process received a signal
70
+ # @raise [CommandNotFoundError] if pt-online-schema-change can't be found
71
+ def validate_status!
72
+ raise SignalError.new(status) if status.signaled? # rubocop:disable Style/RaiseArgs
73
+ raise CommandNotFoundError if status.exitstatus == COMMAND_NOT_FOUND
74
+ raise Error, error_message unless status.success?
75
+ end
76
+
77
+ # Returns the error message that appeared in the process' stderr
78
+ #
79
+ # @return [String]
80
+ def error_message
81
+ File.read(error_log_path)
82
+ end
83
+
84
+ # Logs when the execution started
85
+ def log_started
86
+ logger.write("\n")
87
+ logger.say("Running #{command_line}\n\n", true)
88
+ end
89
+
90
+ # Prints a line break to keep the logs separate from the execution time
91
+ # print by the migration
92
+ def log_finished
93
+ logger.write("\n")
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,20 @@
1
+ module Departure
2
+ class Configuration
3
+ attr_accessor :tmp_path, :global_percona_args, :enabled_by_default
4
+
5
+ def initialize
6
+ @tmp_path = '.'.freeze
7
+ @error_log_filename = 'departure_error.log'.freeze
8
+ @global_percona_args = nil
9
+ @enabled_by_default = true
10
+ end
11
+
12
+ def error_log_path
13
+ File.join(tmp_path, error_log_filename)
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :error_log_filename
19
+ end
20
+ end
@@ -0,0 +1,9 @@
1
+ module Departure
2
+ class ConnectionBase < ActiveRecord::Base
3
+ def self.establish_connection(config = nil)
4
+ super.tap do
5
+ ActiveRecord::Base.connection_specification_name = connection_specification_name
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,96 @@
1
+ require 'shellwords'
2
+ module Departure
3
+ # Holds the parameters of the DB connection and formats them to string
4
+ class ConnectionDetails
5
+ DEFAULT_PORT = 3306
6
+ # Constructor
7
+ #
8
+ # @param [Hash] connection parametes as used in #establish_conneciton
9
+ def initialize(connection_data)
10
+ @connection_data = connection_data
11
+ end
12
+
13
+ # Returns the details formatted as an string to be used with
14
+ # pt-online-schema-change. It follows the mysql client's format.
15
+ #
16
+ # @return [String]
17
+ def to_s
18
+ @to_s ||= "#{host_argument} -P #{port} -u #{user} #{password_argument}"
19
+ end
20
+
21
+ # TODO: Doesn't the abstract adapter already handle this somehow?
22
+ # Returns the database name. If PERCONA_DB_NAME is passed its value will be
23
+ # used instead
24
+ #
25
+ # Returns the database name
26
+ #
27
+ # @return [String]
28
+ def database
29
+ ENV.fetch('PERCONA_DB_NAME', connection_data[:database])
30
+ end
31
+
32
+ # Returns the password fragment of the details string if a password is passed
33
+ #
34
+ # @return [String]
35
+ def password_argument
36
+ if password.present?
37
+ %(--password #{Shellwords.escape(password)} )
38
+ else
39
+ ''
40
+ end
41
+ end
42
+
43
+ # Returns the host fragment of the details string, adds ssl options if needed
44
+ #
45
+ # @return [String]
46
+ def host_argument
47
+ host_string = host
48
+ if ssl_ca.present?
49
+ host_string += ";mysql_ssl=1;mysql_ssl_client_ca=#{ssl_ca}"
50
+ end
51
+ "-h \"#{host_string}\""
52
+ end
53
+
54
+ private
55
+
56
+ attr_reader :connection_data
57
+
58
+ # Returns the database host name, defaulting to localhost. If PERCONA_DB_HOST
59
+ # is passed its value will be used instead
60
+ #
61
+ # @return [String]
62
+ def host
63
+ ENV.fetch('PERCONA_DB_HOST', connection_data[:host]) || 'localhost'
64
+ end
65
+
66
+ # Returns the database user. If PERCONA_DB_USER is passed its value will be
67
+ # used instead
68
+ #
69
+ # @return [String]
70
+ def user
71
+ ENV.fetch('PERCONA_DB_USER', connection_data[:username])
72
+ end
73
+
74
+ # Returns the database user's password. If PERCONA_DB_PASSWORD is passed its
75
+ # value will be used instead
76
+ #
77
+ # @return [String]
78
+ def password
79
+ ENV.fetch('PERCONA_DB_PASSWORD', connection_data[:password])
80
+ end
81
+
82
+ # Returns the database's port.
83
+ #
84
+ # @return [String]
85
+ def port
86
+ connection_data.fetch(:port, DEFAULT_PORT)
87
+ end
88
+
89
+ # Returns the database' SSL CA certificate.
90
+ #
91
+ # @return [String]
92
+ def ssl_ca
93
+ connection_data.fetch(:sslca, nil)
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,24 @@
1
+ module Departure
2
+ # Represents the 'DSN' argument of Percona's pt-online-schema-change
3
+ # See https://www.percona.com/doc/percona-toolkit/2.0/pt-online-schema-change.html#dsn-options
4
+ class DSN
5
+ # Constructor
6
+ #
7
+ # @param database [String, Symbol]
8
+ # @param table_name [String, Symbol]
9
+ def initialize(database, table_name)
10
+ @database = database
11
+ @table_name = table_name
12
+ end
13
+
14
+ # Returns the pt-online-schema-change DSN string. See
15
+ # https://www.percona.com/doc/percona-toolkit/2.0/pt-online-schema-change.html#dsn-options
16
+ def to_s
17
+ "D=#{database},t=#{table_name}"
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :table_name, :database
23
+ end
24
+ end
@@ -0,0 +1,39 @@
1
+ module Departure
2
+ class Error < StandardError; end
3
+
4
+ # Used when for whatever reason we couldn't get the spawned process'
5
+ # status.
6
+ class NoStatusError < Error
7
+ def message
8
+ 'Status could not be retrieved'.freeze
9
+ end
10
+ end
11
+
12
+ # Used when the spawned process failed by receiving a signal.
13
+ # pt-online-schema-change returns "SIGSEGV (signal 11)" on failures.
14
+ class SignalError < Error
15
+ attr_reader :status
16
+
17
+ # Constructor
18
+ #
19
+ # @param status [Process::Status]
20
+ def initialize(status)
21
+ super
22
+ @status = status
23
+ end
24
+
25
+ def message
26
+ status.to_s
27
+ end
28
+ end
29
+
30
+ class CommandNotFoundError < Error
31
+ def message
32
+ 'Please install pt-online-schema-change. Check: https://www.percona.com/doc/percona-toolkit for further details'
33
+ end
34
+ end
35
+
36
+ # Used to prevent running the db:migrate rake task when providing arguments
37
+ # through PERCONA_ARGS env var
38
+ class ArgumentsNotSupported < Error; end
39
+ end
@@ -0,0 +1,22 @@
1
+ module Departure
2
+ module LogSanitizers
3
+ class PasswordSanitizer
4
+ PASSWORD_REPLACEMENT = '[filtered_password]'.freeze
5
+
6
+ delegate :password_argument, to: :connection_details
7
+
8
+ def initialize(connection_details)
9
+ @connection_details = connection_details
10
+ end
11
+
12
+ def execute(log_statement)
13
+ return log_statement if password_argument.blank?
14
+ log_statement.gsub(password_argument, PASSWORD_REPLACEMENT)
15
+ end
16
+
17
+ private
18
+
19
+ attr_accessor :connection_details
20
+ end
21
+ end
22
+ end