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