arrival 0.1.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 (53) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +8 -0
  3. data/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
  4. data/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  5. data/.gitignore +13 -0
  6. data/.rspec +2 -0
  7. data/.rubocop.yml +59 -0
  8. data/.travis.yml +20 -0
  9. data/CHANGELOG.md +184 -0
  10. data/CODE_OF_CONDUCT.md +50 -0
  11. data/Dockerfile +39 -0
  12. data/Gemfile +6 -0
  13. data/LICENSE.txt +22 -0
  14. data/README.md +209 -0
  15. data/RELEASING.md +17 -0
  16. data/Rakefile +17 -0
  17. data/arrival.gemspec +29 -0
  18. data/bin/console +14 -0
  19. data/bin/rspec +16 -0
  20. data/bin/setup +7 -0
  21. data/config.yml.erb +4 -0
  22. data/configuration.rb +16 -0
  23. data/docker-compose-inspiration.yml +44 -0
  24. data/docker-compose.yml +40 -0
  25. data/lib/active_record/connection_adapters/for_alter.rb +91 -0
  26. data/lib/active_record/connection_adapters/percona_adapter.rb +158 -0
  27. data/lib/arrival.rb +68 -0
  28. data/lib/arrival/alter_argument.rb +43 -0
  29. data/lib/arrival/cli_generator.rb +85 -0
  30. data/lib/arrival/command.rb +96 -0
  31. data/lib/arrival/configuration.rb +19 -0
  32. data/lib/arrival/connection_details.rb +96 -0
  33. data/lib/arrival/dsn.rb +24 -0
  34. data/lib/arrival/errors.rb +39 -0
  35. data/lib/arrival/log_sanitizers/password_sanitizer.rb +22 -0
  36. data/lib/arrival/logger.rb +42 -0
  37. data/lib/arrival/logger_factory.rb +16 -0
  38. data/lib/arrival/null_logger.rb +15 -0
  39. data/lib/arrival/option.rb +75 -0
  40. data/lib/arrival/railtie.rb +28 -0
  41. data/lib/arrival/runner.rb +62 -0
  42. data/lib/arrival/user_options.rb +44 -0
  43. data/lib/arrival/version.rb +3 -0
  44. data/lib/lhm.rb +23 -0
  45. data/lib/lhm/adapter.rb +107 -0
  46. data/lib/lhm/column_with_sql.rb +96 -0
  47. data/lib/lhm/column_with_type.rb +29 -0
  48. data/main/conf/mysql.conf.cnf +9 -0
  49. data/main/mysql_main.env +7 -0
  50. data/replica/conf/mysql.conf.cnf +10 -0
  51. data/replica/mysql_replica.env +7 -0
  52. data/test_database.rb +80 -0
  53. metadata +220 -0
@@ -0,0 +1,96 @@
1
+ module Arrival
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,19 @@
1
+ module Arrival
2
+ class Configuration
3
+ attr_accessor :tmp_path, :global_percona_args
4
+
5
+ def initialize
6
+ @tmp_path = '.'.freeze
7
+ @error_log_filename = 'arrival_error.log'.freeze
8
+ @global_percona_args = nil
9
+ end
10
+
11
+ def error_log_path
12
+ File.join(tmp_path, error_log_filename)
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :error_log_filename
18
+ end
19
+ end
@@ -0,0 +1,96 @@
1
+ require 'shellwords'
2
+ module Arrival
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 Arrival
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 Arrival
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 Arrival
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
@@ -0,0 +1,42 @@
1
+ module Arrival
2
+ # Copies the ActiveRecord::Migration #say and #write plus a new
3
+ # #write_no_newline to log the migration's status. It's not possible to reuse
4
+ # the from ActiveRecord::Migration because the migration's instance can't be
5
+ # seen from the connection adapter.
6
+ class Logger
7
+ def initialize(sanitizers)
8
+ @sanitizers = sanitizers
9
+ end
10
+
11
+ # Outputs the message through the stdout, following the
12
+ # ActiveRecord::Migration log format
13
+ #
14
+ # @param message [String]
15
+ # @param subitem [Boolean] whether to show message as a nested log item
16
+ def say(message, subitem = false)
17
+ write "#{subitem ? ' ->' : '--'} #{message}"
18
+ end
19
+
20
+ # Outputs the text through the stdout adding a new line at the end
21
+ #
22
+ # @param text [String]
23
+ def write(text = '')
24
+ puts(sanitize(text))
25
+ end
26
+
27
+ # Outputs the text through the stdout without adding a new line at the end
28
+ #
29
+ # @param text [String]
30
+ def write_no_newline(text)
31
+ print(sanitize(text))
32
+ end
33
+
34
+ private
35
+
36
+ attr_accessor :sanitizers
37
+
38
+ def sanitize(text)
39
+ sanitizers.inject(text) { |memo, sanitizer| sanitizer.execute(memo) }
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,16 @@
1
+ module Arrival
2
+ module LoggerFactory
3
+ # Returns the appropriate logger instance for the given configuration. Use
4
+ # :verbose option to log to the stdout
5
+ #
6
+ # @param verbose [Boolean]
7
+ # @return [#say, #write]
8
+ def self.build(sanitizers: [], verbose: true)
9
+ if verbose
10
+ Arrival::Logger.new(sanitizers)
11
+ else
12
+ Arrival::NullLogger.new
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ module Arrival
2
+ class NullLogger
3
+ def say(_message, _subitem = false)
4
+ # noop
5
+ end
6
+
7
+ def write(_text)
8
+ # noop
9
+ end
10
+
11
+ def write_no_newline(_text)
12
+ # noop
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,75 @@
1
+ module Arrival
2
+ class Option
3
+ attr_reader :name, :value
4
+
5
+ # Builds an instance by parsing its name and value out of the given string.
6
+ #
7
+ # @param string [String]
8
+ # @return [Option]
9
+ def self.from_string(string)
10
+ name, value = string.split(/\s|=/, 2)
11
+ new(name, value)
12
+ end
13
+
14
+ # Constructor
15
+ #
16
+ # @param name [String]
17
+ # @param optional value [String]
18
+ def initialize(name, value = nil)
19
+ @name = normalize_option(name)
20
+ @value = value
21
+ end
22
+
23
+ # Compares two options
24
+ #
25
+ # @param [Option]
26
+ # @return [Boolean]
27
+ def ==(other)
28
+ name == other.name
29
+ end
30
+ alias eql? ==
31
+
32
+ # Returns the option's hash
33
+ #
34
+ # @return [Fixnum]
35
+ def hash
36
+ name.hash
37
+ end
38
+
39
+ # Returns the option as string following the "--<name>=<value>" format or
40
+ # the short "-n=value" format
41
+ #
42
+ # @return [String]
43
+ def to_s
44
+ "#{name}#{value_as_string}"
45
+ end
46
+
47
+ private
48
+
49
+ # Returns the option name in "long" format, e.g., "--name"
50
+ #
51
+ # @return [String]
52
+ def normalize_option(name)
53
+ if name.start_with?('-')
54
+ name
55
+ elsif name.length == 1
56
+ "-#{name}"
57
+ else
58
+ "--#{name}"
59
+ end
60
+ end
61
+
62
+ # Returns the value fragment of the option string if any value is specified
63
+ #
64
+ # @return [String]
65
+ def value_as_string
66
+ if value.nil?
67
+ ''
68
+ elsif value.include?('=')
69
+ " #{value}"
70
+ else
71
+ "=#{value}"
72
+ end
73
+ end
74
+ end
75
+ end