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.
- checksums.yaml +7 -0
- data/.codeclimate.yml +8 -0
- data/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
- data/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
- data/.gitignore +13 -0
- data/.rspec +2 -0
- data/.rubocop.yml +59 -0
- data/.travis.yml +20 -0
- data/CHANGELOG.md +184 -0
- data/CODE_OF_CONDUCT.md +50 -0
- data/Dockerfile +39 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +22 -0
- data/README.md +209 -0
- data/RELEASING.md +17 -0
- data/Rakefile +17 -0
- data/arrival.gemspec +29 -0
- data/bin/console +14 -0
- data/bin/rspec +16 -0
- data/bin/setup +7 -0
- data/config.yml.erb +4 -0
- data/configuration.rb +16 -0
- data/docker-compose-inspiration.yml +44 -0
- data/docker-compose.yml +40 -0
- data/lib/active_record/connection_adapters/for_alter.rb +91 -0
- data/lib/active_record/connection_adapters/percona_adapter.rb +158 -0
- data/lib/arrival.rb +68 -0
- data/lib/arrival/alter_argument.rb +43 -0
- data/lib/arrival/cli_generator.rb +85 -0
- data/lib/arrival/command.rb +96 -0
- data/lib/arrival/configuration.rb +19 -0
- data/lib/arrival/connection_details.rb +96 -0
- data/lib/arrival/dsn.rb +24 -0
- data/lib/arrival/errors.rb +39 -0
- data/lib/arrival/log_sanitizers/password_sanitizer.rb +22 -0
- data/lib/arrival/logger.rb +42 -0
- data/lib/arrival/logger_factory.rb +16 -0
- data/lib/arrival/null_logger.rb +15 -0
- data/lib/arrival/option.rb +75 -0
- data/lib/arrival/railtie.rb +28 -0
- data/lib/arrival/runner.rb +62 -0
- data/lib/arrival/user_options.rb +44 -0
- data/lib/arrival/version.rb +3 -0
- data/lib/lhm.rb +23 -0
- data/lib/lhm/adapter.rb +107 -0
- data/lib/lhm/column_with_sql.rb +96 -0
- data/lib/lhm/column_with_type.rb +29 -0
- data/main/conf/mysql.conf.cnf +9 -0
- data/main/mysql_main.env +7 -0
- data/replica/conf/mysql.conf.cnf +10 -0
- data/replica/mysql_replica.env +7 -0
- data/test_database.rb +80 -0
- 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
|
data/lib/arrival/dsn.rb
ADDED
@@ -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,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
|