kamal-backup 0.3.0.beta21 → 0.3.1

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.
@@ -0,0 +1,376 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require_relative 'errors'
5
+ require_relative 'databases/base'
6
+
7
+ module KamalBackup
8
+ ConfigData = Struct.new(:env, :database_definitions, :path_definitions, :restore_from_definitions,
9
+ keyword_init: true) do
10
+ def self.empty
11
+ new(env: {}, database_definitions: nil, path_definitions: nil, restore_from_definitions: nil)
12
+ end
13
+ end
14
+
15
+ PathDefinition = Struct.new(:path, :exclude, keyword_init: true)
16
+
17
+ # Parses one config/kamal-backup.yml into ConfigData. Parsed values resolve
18
+ # to the same env keys the backup accessory receives, so process ENV always
19
+ # wins over file config through a single merge in Config.
20
+ class ConfigFile
21
+ TOP_LEVEL_KEYS = %w[app accessory databases paths restore_from restic backup state].freeze
22
+ LEGACY_KEYS = %w[
23
+ app_name
24
+ database_adapter
25
+ database_url
26
+ sqlite_database_path
27
+ backup_paths
28
+ local_restore_source_paths
29
+ restic_repository
30
+ restic_repository_file
31
+ restic_password
32
+ restic_password_file
33
+ restic_password_command
34
+ restic_init_if_missing
35
+ restic_check_after_backup
36
+ restic_check_read_data_subset
37
+ restic_forget_after_backup
38
+ restic_keep_last
39
+ restic_keep_daily
40
+ restic_keep_weekly
41
+ restic_keep_monthly
42
+ restic_keep_yearly
43
+ backup_schedule_seconds
44
+ backup_start_delay_seconds
45
+ state_dir
46
+ allow_suspicious_paths
47
+ pgpassword
48
+ mysql_pwd
49
+ ].freeze
50
+
51
+ def initialize(path, env:)
52
+ @path = path
53
+ @env = env
54
+ end
55
+
56
+ def data
57
+ raw = YAML.safe_load(File.read(@path), permitted_classes: [], aliases: false)
58
+ return ConfigData.empty if raw.nil?
59
+
60
+ raise ConfigurationError, "#{@path} must contain a YAML mapping" unless raw.is_a?(Hash)
61
+
62
+ raw.each_with_object(ConfigData.empty) do |(raw_key, raw_value), result|
63
+ key = raw_key.to_s
64
+ validate_top_level_key!(key)
65
+ apply(result, key, raw_value)
66
+ end
67
+ rescue Psych::SyntaxError => e
68
+ raise ConfigurationError, "invalid YAML in #{@path}: #{e.message}"
69
+ end
70
+
71
+ private
72
+
73
+ def apply(result, key, raw_value)
74
+ case key
75
+ when 'app'
76
+ result.env['APP_NAME'] = normalize_value(raw_value)
77
+ when 'accessory'
78
+ result.env['KAMAL_BACKUP_ACCESSORY'] = normalize_value(raw_value)
79
+ when 'databases'
80
+ result.database_definitions = databases(raw_value)
81
+ when 'paths'
82
+ result.path_definitions = backup_path_definitions(raw_value, "#{@path} paths")
83
+ when 'restore_from'
84
+ result.restore_from_definitions = path_list(raw_value, "#{@path} restore_from")
85
+ when 'restic'
86
+ result.env.merge!(restic_env(raw_value))
87
+ when 'backup'
88
+ result.env.merge!(backup_env(raw_value))
89
+ when 'state'
90
+ result.env.merge!(state_env(raw_value))
91
+ end
92
+ end
93
+
94
+ def validate_top_level_key!(key)
95
+ if LEGACY_KEYS.include?(key)
96
+ raise ConfigurationError,
97
+ "#{@path} uses legacy key #{key}; use databases, paths, restic, and backup instead. See the upgrading guide for the 0.3 config migration."
98
+ end
99
+
100
+ return if TOP_LEVEL_KEYS.include?(key)
101
+
102
+ raise ConfigurationError,
103
+ "#{@path} contains unknown key #{key.inspect}; expected #{TOP_LEVEL_KEYS.join(', ')}"
104
+ end
105
+
106
+ def databases(raw_value)
107
+ entries = require_array(raw_value, "#{@path} databases")
108
+ entries.map.with_index(1) do |entry, index|
109
+ context = "#{@path} databases[#{index}]"
110
+ hash = require_mapping(entry, context)
111
+ name = required_scalar(hash, 'name', context)
112
+ adapter = Databases.normalize_adapter(required_scalar(hash, 'adapter', context))
113
+ raise ConfigurationError, "#{context} adapter must be postgres, mysql, or sqlite" unless adapter
114
+
115
+ env = {}
116
+ missing_secrets = []
117
+ database_connection_sources(hash, adapter, context).each do |env_key, raw, value_context|
118
+ env[env_key] = resolve_value(raw, context: value_context)
119
+ missing_secrets.concat(missing_secrets_for(raw))
120
+ end
121
+
122
+ {
123
+ name: name,
124
+ adapter: adapter,
125
+ env: env.compact,
126
+ missing_secrets: missing_secrets
127
+ }
128
+ end
129
+ end
130
+
131
+ def database_connection_sources(hash, adapter, context)
132
+ case adapter
133
+ when 'postgres', 'mysql'
134
+ password_key = adapter == 'postgres' ? 'PGPASSWORD' : 'MYSQL_PWD'
135
+ [].tap do |sources|
136
+ sources << ['DATABASE_URL', hash['url'], "#{context}.url"] if hash.key?('url')
137
+ sources << [password_key, hash['password'], "#{context}.password"] if hash.key?('password')
138
+ end
139
+ when 'sqlite'
140
+ sqlite_path = hash.key?('path') ? hash['path'] : hash['database']
141
+ sqlite_path ? [['SQLITE_DATABASE_PATH', sqlite_path, "#{context}.path"]] : []
142
+ end
143
+ end
144
+
145
+ def backup_path_definitions(raw_value, context)
146
+ definitions =
147
+ case raw_value
148
+ when Array
149
+ raw_value.map.with_index(1) { |entry, index| backup_path_definition(entry, "#{context}[#{index}]") }
150
+ when NilClass
151
+ []
152
+ else
153
+ [backup_path_definition(raw_value, "#{context}[1]")]
154
+ end
155
+
156
+ definitions.reject { |definition| definition.path.to_s.empty? }
157
+ end
158
+
159
+ def backup_path_definition(raw_value, context)
160
+ case raw_value
161
+ when Hash
162
+ hash = require_mapping(raw_value, context)
163
+ unknown_keys = hash.keys - %w[path exclude]
164
+ unless unknown_keys.empty?
165
+ raise ConfigurationError,
166
+ "#{context} contains unknown key #{unknown_keys.first.inspect}; expected path and exclude"
167
+ end
168
+
169
+ path = required_string(hash, 'path', context)
170
+ exclude = hash.key?('exclude') ? exclude_patterns(hash['exclude'], "#{context}.exclude") : []
171
+ PathDefinition.new(path: path, exclude: exclude)
172
+ when Array
173
+ raise ConfigurationError, "#{context} must be a path string or a mapping with path and optional exclude"
174
+ else
175
+ PathDefinition.new(path: normalize_value(raw_value), exclude: [])
176
+ end
177
+ end
178
+
179
+ def exclude_patterns(raw_value, context)
180
+ entries = require_array(raw_value, context)
181
+ entries.map.with_index(1) do |entry, index|
182
+ pattern = optional_string(entry, "#{context}[#{index}]")
183
+ raise ConfigurationError, "#{context}[#{index}] must not be empty" if pattern.to_s.strip.empty?
184
+
185
+ pattern
186
+ end
187
+ end
188
+
189
+ def path_list(raw_value, context)
190
+ case raw_value
191
+ when Array
192
+ raw_value.map { |path| path_string(path, context) }.reject(&:empty?)
193
+ when NilClass
194
+ []
195
+ else
196
+ [path_string(raw_value, context)].reject(&:empty?)
197
+ end
198
+ end
199
+
200
+ def path_string(raw_value, context)
201
+ raise ConfigurationError, "#{context} entries must be path strings" if raw_value.is_a?(Hash) || raw_value.is_a?(Array)
202
+
203
+ normalize_value(raw_value)
204
+ end
205
+
206
+ def restic_env(raw_value)
207
+ hash = require_mapping(raw_value, "#{@path} restic")
208
+ env = {}
209
+
210
+ env['RESTIC_REPOSITORY'] = resolve_value(hash['repository'], context: "#{@path} restic.repository") if hash.key?('repository')
211
+ env['RESTIC_REPOSITORY_FILE'] = normalize_value(hash['repository_file']) if hash.key?('repository_file')
212
+ env.merge!(restic_password_env(hash['password'])) if hash.key?('password')
213
+ env.merge!(restic_rest_env(hash['rest'])) if hash.key?('rest')
214
+
215
+ {
216
+ 'init_if_missing' => 'RESTIC_INIT_IF_MISSING',
217
+ 'check_after_backup' => 'RESTIC_CHECK_AFTER_BACKUP',
218
+ 'check_read_data_subset' => 'RESTIC_CHECK_READ_DATA_SUBSET',
219
+ 'forget_after_backup' => 'RESTIC_FORGET_AFTER_BACKUP'
220
+ }.each do |source, target|
221
+ env[target] = normalize_value(hash[source]) if hash.key?(source)
222
+ end
223
+
224
+ env.merge!(retention_env(hash['retention'])) if hash.key?('retention')
225
+ env.compact
226
+ end
227
+
228
+ def restic_password_env(raw_value)
229
+ case raw_value
230
+ when Hash
231
+ hash = stringify_keys(raw_value)
232
+ if hash.key?('secret')
233
+ { 'RESTIC_PASSWORD' => resolve_value(hash, context: "#{@path} restic.password") }
234
+ elsif hash.key?('file')
235
+ { 'RESTIC_PASSWORD_FILE' => normalize_value(hash['file']) }
236
+ elsif hash.key?('command')
237
+ { 'RESTIC_PASSWORD_COMMAND' => normalize_value(hash['command']) }
238
+ else
239
+ raise ConfigurationError, "#{@path} restic.password must use secret, file, or command"
240
+ end
241
+ else
242
+ { 'RESTIC_PASSWORD' => normalize_value(raw_value) }
243
+ end
244
+ end
245
+
246
+ def restic_rest_env(raw_value)
247
+ hash = require_mapping(raw_value, "#{@path} restic.rest")
248
+ env = {}
249
+ username = hash.key?('username') ? hash['username'] : hash['user']
250
+
251
+ env['RESTIC_REST_USERNAME'] = resolve_value(username, context: "#{@path} restic.rest.username") if username
252
+ env['RESTIC_REST_PASSWORD'] = resolve_value(hash['password'], context: "#{@path} restic.rest.password") if hash.key?('password')
253
+ env.compact
254
+ end
255
+
256
+ def retention_env(raw_value)
257
+ retention = require_mapping(raw_value, "#{@path} restic.retention")
258
+
259
+ {
260
+ 'keep_last' => 'RESTIC_KEEP_LAST',
261
+ 'keep_daily' => 'RESTIC_KEEP_DAILY',
262
+ 'keep_weekly' => 'RESTIC_KEEP_WEEKLY',
263
+ 'keep_monthly' => 'RESTIC_KEEP_MONTHLY',
264
+ 'keep_yearly' => 'RESTIC_KEEP_YEARLY'
265
+ }.each_with_object({}) do |(source, target), env|
266
+ env[target] = normalize_value(retention[source]) if retention.key?(source)
267
+ end
268
+ end
269
+
270
+ def backup_env(raw_value)
271
+ hash = require_mapping(raw_value, "#{@path} backup")
272
+ env = {}
273
+ env['BACKUP_SCHEDULE_SECONDS'] = normalize_duration(hash['schedule'], "#{@path} backup.schedule") if hash.key?('schedule')
274
+ env.compact
275
+ end
276
+
277
+ def normalize_duration(raw_value, context)
278
+ value = normalize_value(raw_value)
279
+ raise ConfigurationError, "#{context} is required" if value.to_s.empty?
280
+
281
+ return value if value.match?(/\A\d+\z/)
282
+
283
+ match = value.match(/\A(\d+)\s*([smhdw])\z/i)
284
+ raise ConfigurationError, "#{context} must be seconds or a duration like 30m, 6h, or 1d" unless match
285
+
286
+ amount = match[1].to_i
287
+ multiplier = {
288
+ 's' => 1,
289
+ 'm' => 60,
290
+ 'h' => 3600,
291
+ 'd' => 86_400,
292
+ 'w' => 604_800
293
+ }.fetch(match[2].downcase)
294
+ (amount * multiplier).to_s
295
+ end
296
+
297
+ def state_env(raw_value)
298
+ hash = require_mapping(raw_value, "#{@path} state")
299
+ env = {}
300
+ env['KAMAL_BACKUP_STATE_DIR'] = normalize_value(hash['path']) if hash.key?('path')
301
+ env.compact
302
+ end
303
+
304
+ def resolve_value(raw_value, context:)
305
+ case raw_value
306
+ when Hash
307
+ hash = stringify_keys(raw_value)
308
+ raise ConfigurationError, "#{context} must be a scalar value or { secret: NAME }" unless hash.keys == ['secret']
309
+
310
+ secret_name = normalize_value(hash.fetch('secret'))
311
+ @env[secret_name]
312
+ else
313
+ normalize_value(raw_value)
314
+ end
315
+ end
316
+
317
+ def missing_secrets_for(raw_value)
318
+ return [] unless raw_value.is_a?(Hash)
319
+
320
+ hash = stringify_keys(raw_value)
321
+ return [] unless hash.key?('secret')
322
+
323
+ secret_name = normalize_value(hash.fetch('secret'))
324
+ @env[secret_name].to_s.strip.empty? ? [secret_name] : []
325
+ end
326
+
327
+ def normalize_value(raw_value)
328
+ case raw_value
329
+ when Array
330
+ raw_value.map(&:to_s).join("\n")
331
+ when NilClass
332
+ nil
333
+ else
334
+ raw_value.to_s
335
+ end
336
+ end
337
+
338
+ def require_array(value, context)
339
+ return value if value.is_a?(Array)
340
+
341
+ raise ConfigurationError, "#{context} must be a YAML sequence"
342
+ end
343
+
344
+ def require_mapping(value, context)
345
+ raise ConfigurationError, "#{context} must be a YAML mapping" unless value.is_a?(Hash)
346
+
347
+ stringify_keys(value)
348
+ end
349
+
350
+ def optional_string(value, context)
351
+ raise ConfigurationError, "#{context} must be a string" if value.is_a?(Hash) || value.is_a?(Array)
352
+
353
+ normalize_value(value)
354
+ end
355
+
356
+ def required_string(hash, key, context)
357
+ raise ConfigurationError, "#{context} #{key} is required" unless hash.key?(key)
358
+
359
+ value = optional_string(hash[key], "#{context}.#{key}")
360
+ raise ConfigurationError, "#{context} #{key} is required" if value.to_s.strip.empty?
361
+
362
+ value
363
+ end
364
+
365
+ def required_scalar(hash, key, context)
366
+ value = normalize_value(hash[key])
367
+ raise ConfigurationError, "#{context} #{key} is required" if value.to_s.empty?
368
+
369
+ value
370
+ end
371
+
372
+ def stringify_keys(hash)
373
+ hash.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
374
+ end
375
+ end
376
+ end
@@ -1,16 +1,29 @@
1
- require_relative "../command"
2
- require_relative "../errors"
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../command'
4
+ require_relative '../errors'
3
5
 
4
6
  module KamalBackup
5
7
  module Databases
8
+ def self.normalize_adapter(value)
9
+ case value.to_s.downcase
10
+ when 'postgres', 'postgresql'
11
+ 'postgres'
12
+ when 'mysql', 'mysql2', 'mariadb'
13
+ 'mysql'
14
+ when 'sqlite', 'sqlite3'
15
+ 'sqlite'
16
+ end
17
+ end
18
+
6
19
  class Base
7
20
  def self.build(config, redactor:)
8
21
  case config.database_adapter
9
- when "postgres"
22
+ when 'postgres'
10
23
  Postgres.new(config, redactor: redactor)
11
- when "mysql"
24
+ when 'mysql'
12
25
  Mysql.new(config, redactor: redactor)
13
- when "sqlite"
26
+ when 'sqlite'
14
27
  Sqlite.new(config, redactor: redactor)
15
28
  else
16
29
  raise ConfigurationError, "unsupported DATABASE_ADAPTER: #{config.database_adapter.inspect}"
@@ -42,13 +55,13 @@ module KamalBackup
42
55
  end
43
56
 
44
57
  def database_filename
45
- app = config.app_name.gsub(/[^A-Za-z0-9_.-]+/, "-")
46
- database = config.database_name.gsub(/[^A-Za-z0-9_.-]+/, "-")
58
+ app = config.app_name.gsub(/[^A-Za-z0-9_.-]+/, '-')
59
+ database = config.database_name.gsub(/[^A-Za-z0-9_.-]+/, '-')
47
60
  "databases/#{app}/#{database}/#{adapter_name}.#{dump_extension}"
48
61
  end
49
62
 
50
63
  def backup_tags
51
- ["type:database", "database:#{config.database_name}", "adapter:#{adapter_name}"]
64
+ ['type:database', "database:#{config.database_name}", "adapter:#{adapter_name}"]
52
65
  end
53
66
 
54
67
  def adapter_name
@@ -84,13 +97,14 @@ module KamalBackup
84
97
  end
85
98
 
86
99
  private
87
- def value(key)
88
- config.value(key)
89
- end
90
100
 
91
- def executable_available?(name)
92
- Command.available?(name)
93
- end
101
+ def value(key)
102
+ config.value(key)
103
+ end
104
+
105
+ def executable_available?(name)
106
+ Command.available?(name)
107
+ end
94
108
  end
95
109
  end
96
110
  end
@@ -1,26 +1,28 @@
1
- require "uri"
2
- require_relative "base"
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require_relative 'base'
3
5
 
4
6
  module KamalBackup
5
7
  module Databases
6
8
  class Mysql < Base
7
9
  def adapter_name
8
- "mysql"
10
+ 'mysql'
9
11
  end
10
12
 
11
13
  def dump_extension
12
- "sql"
14
+ 'sql'
13
15
  end
14
16
 
15
17
  def dump_command
16
18
  connection = current_connection
17
19
  argv = [
18
20
  dump_binary,
19
- "--single-transaction",
20
- "--quick",
21
- "--routines",
22
- "--triggers",
23
- "--events"
21
+ '--single-transaction',
22
+ '--quick',
23
+ '--routines',
24
+ '--triggers',
25
+ '--events'
24
26
  ] + connection_args(connection)
25
27
  argv << connection.fetch(:database)
26
28
  CommandSpec.new(argv: argv, env: password_env(connection))
@@ -42,80 +44,79 @@ module KamalBackup
42
44
 
43
45
  def current_target_identifier
44
46
  connection = current_connection
45
- [connection[:host], connection[:database]].compact.join("/")
47
+ [connection[:host], connection[:database]].compact.join('/')
46
48
  end
47
49
 
48
50
  def scratch_target_identifier(target)
49
- [current_connection[:host], target].compact.join("/")
51
+ [current_connection[:host], target].compact.join('/')
50
52
  end
51
53
 
52
54
  private
53
- def validate_scratch_restore_target(target)
54
- if current_connection.fetch(:database) == target
55
- raise ConfigurationError, "scratch database must differ from the current MySQL database"
56
- end
57
55
 
58
- super
59
- end
56
+ def validate_scratch_restore_target(target)
57
+ raise ConfigurationError, 'scratch database must differ from the current MySQL database' if current_connection.fetch(:database) == target
60
58
 
61
- def dump_binary
62
- value("MYSQL_DUMP_BIN") || (executable_available?("mariadb-dump") ? "mariadb-dump" : "mysqldump")
63
- end
59
+ super
60
+ end
64
61
 
65
- def client_binary
66
- value("MYSQL_CLIENT_BIN") || (executable_available?("mariadb") ? "mariadb" : "mysql")
67
- end
62
+ def dump_binary
63
+ value('MYSQL_DUMP_BIN') || (executable_available?('mariadb-dump') ? 'mariadb-dump' : 'mysqldump')
64
+ end
65
+
66
+ def client_binary
67
+ value('MYSQL_CLIENT_BIN') || (executable_available?('mariadb') ? 'mariadb' : 'mysql')
68
+ end
68
69
 
69
- def current_connection
70
- if value("DATABASE_URL")
71
- parse_url(value("DATABASE_URL")).tap do |connection|
72
- connection[:password] ||= value("MYSQL_PWD") || value("MYSQL_PASSWORD") || value("MARIADB_PASSWORD")
73
- end
74
- else
75
- connection_from_env("")
70
+ def current_connection
71
+ if value('DATABASE_URL')
72
+ parse_url(value('DATABASE_URL')).tap do |connection|
73
+ connection[:password] ||= value('MYSQL_PWD') || value('MYSQL_PASSWORD') || value('MARIADB_PASSWORD')
76
74
  end
75
+ else
76
+ connection_from_env('')
77
77
  end
78
+ end
78
79
 
79
- def connection_from_env(prefix)
80
- database = value("#{prefix}MYSQL_DATABASE") || value("#{prefix}MARIADB_DATABASE")
81
- raise ConfigurationError, "#{prefix}MYSQL_DATABASE or #{prefix}MARIADB_DATABASE is required" unless database
82
-
83
- {
84
- host: value("#{prefix}MYSQL_HOST") || value("#{prefix}MARIADB_HOST"),
85
- port: value("#{prefix}MYSQL_PORT") || value("#{prefix}MARIADB_PORT"),
86
- user: value("#{prefix}MYSQL_USER") || value("#{prefix}MARIADB_USER"),
87
- password: value("#{prefix}MYSQL_PWD") || value("#{prefix}MYSQL_PASSWORD") || value("#{prefix}MARIADB_PASSWORD"),
88
- database: database
89
- }
90
- end
80
+ def connection_from_env(prefix)
81
+ database = value("#{prefix}MYSQL_DATABASE") || value("#{prefix}MARIADB_DATABASE")
82
+ raise ConfigurationError, "#{prefix}MYSQL_DATABASE or #{prefix}MARIADB_DATABASE is required" unless database
83
+
84
+ {
85
+ host: value("#{prefix}MYSQL_HOST") || value("#{prefix}MARIADB_HOST"),
86
+ port: value("#{prefix}MYSQL_PORT") || value("#{prefix}MARIADB_PORT"),
87
+ user: value("#{prefix}MYSQL_USER") || value("#{prefix}MARIADB_USER"),
88
+ password: value("#{prefix}MYSQL_PWD") || value("#{prefix}MYSQL_PASSWORD") || value("#{prefix}MARIADB_PASSWORD"),
89
+ database: database
90
+ }
91
+ end
91
92
 
92
- def parse_url(url)
93
- uri = URI.parse(url)
94
- database = uri.path.to_s.sub(%r{\A/}, "")
95
- raise ConfigurationError, "database name is missing in #{uri.scheme} DATABASE_URL" if database.empty?
96
-
97
- {
98
- host: uri.host,
99
- port: uri.port,
100
- user: uri.user ? URI.decode_www_form_component(uri.user) : nil,
101
- password: uri.password ? URI.decode_www_form_component(uri.password) : nil,
102
- database: URI.decode_www_form_component(database)
103
- }
104
- rescue URI::InvalidURIError => e
105
- raise ConfigurationError, "invalid database URL: #{e.message}"
106
- end
93
+ def parse_url(url)
94
+ uri = URI.parse(url)
95
+ database = uri.path.to_s.sub(%r{\A/}, '')
96
+ raise ConfigurationError, "database name is missing in #{uri.scheme} DATABASE_URL" if database.empty?
97
+
98
+ {
99
+ host: uri.host,
100
+ port: uri.port,
101
+ user: uri.user ? URI.decode_www_form_component(uri.user) : nil,
102
+ password: uri.password ? URI.decode_www_form_component(uri.password) : nil,
103
+ database: URI.decode_www_form_component(database)
104
+ }
105
+ rescue URI::InvalidURIError => e
106
+ raise ConfigurationError, "invalid database URL: #{e.message}"
107
+ end
107
108
 
108
- def connection_args(connection)
109
- args = []
110
- args.concat(["--host", connection[:host]]) if connection[:host]
111
- args.concat(["--port", connection[:port].to_s]) if connection[:port]
112
- args.concat(["--user", connection[:user]]) if connection[:user]
113
- args
114
- end
109
+ def connection_args(connection)
110
+ args = []
111
+ args.concat(['--host', connection[:host]]) if connection[:host]
112
+ args.concat(['--port', connection[:port].to_s]) if connection[:port]
113
+ args.concat(['--user', connection[:user]]) if connection[:user]
114
+ args
115
+ end
115
116
 
116
- def password_env(connection)
117
- connection[:password] ? { "MYSQL_PWD" => connection[:password] } : {}
118
- end
117
+ def password_env(connection)
118
+ connection[:password] ? { 'MYSQL_PWD' => connection[:password] } : {}
119
+ end
119
120
  end
120
121
  end
121
122
  end