departure-76c9880 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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