arrival 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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