kamal-backup 0.3.0 → 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
@@ -5,6 +5,17 @@ require_relative '../errors'
5
5
 
6
6
  module KamalBackup
7
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
+
8
19
  class Base
9
20
  def self.build(config, redactor:)
10
21
  case config.database_adapter
@@ -69,9 +69,9 @@ module KamalBackup
69
69
 
70
70
  def current_connection
71
71
  if value('DATABASE_URL')
72
- connection_from_url(value('DATABASE_URL'), 'DATABASE_URL').tap do |connection|
73
- connection['PGPASSWORD'] ||= value('PGPASSWORD') if value('PGPASSWORD')
74
- end
72
+ connection = connection_from_url(value('DATABASE_URL'), 'DATABASE_URL')
73
+ connection['PGPASSWORD'] ||= value('PGPASSWORD')
74
+ connection.compact
75
75
  else
76
76
  connection = prefixed_env('', SOURCE_ENV_KEYS)
77
77
  unless connection['PGDATABASE']
@@ -19,16 +19,13 @@ module KamalBackup
19
19
  kind: 'evidence',
20
20
  app_name: @config.app_name,
21
21
  generated_at: Time.now.utc.iso8601,
22
- database_adapter: @config.database_adapter,
23
22
  databases: @config.databases.map do |database|
24
23
  { name: database.database_name, adapter: database.database_adapter }
25
24
  end,
26
25
  restic_repository: @redactor.redact_string(@config.restic_repository.to_s),
27
- backup_paths: @config.backup_paths,
28
26
  paths: @config.backup_paths,
29
27
  forget_after_backup: @config.forget_after_backup?,
30
28
  retention: @config.retention,
31
- latest_database_backup: latest_snapshot_summary(['type:database']),
32
29
  latest_database_backups: latest_database_backups,
33
30
  latest_file_backup: latest_snapshot_summary(['type:files']),
34
31
  last_restic_check: last_check,
@@ -3,12 +3,30 @@
3
3
  require 'shellwords'
4
4
  require 'yaml'
5
5
  require_relative 'command'
6
+ require_relative 'yaml_access'
6
7
 
7
8
  module KamalBackup
8
9
  class KamalBridge
10
+ include YamlAccess
11
+
9
12
  DEFAULT_CONFIG_FILE = 'config/deploy.yml'
10
13
  VERSION_LINE_PATTERN = /\A\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*\z/
11
14
 
15
+ class FilteringIO
16
+ def initialize(io, &reject)
17
+ @io = io
18
+ @reject = reject
19
+ end
20
+
21
+ def print(output)
22
+ @io.print(output) unless @reject.call(output.to_s)
23
+ end
24
+
25
+ def flush
26
+ @io.flush if @io.respond_to?(:flush)
27
+ end
28
+ end
29
+
12
30
  def initialize(redactor:, config_file: nil, destination: nil, env: ENV, cwd: Dir.pwd, stdout: $stdout,
13
31
  stderr: $stderr)
14
32
  @redactor = redactor
@@ -57,15 +75,17 @@ module KamalBackup
57
75
  end
58
76
 
59
77
  def execute_on_accessory(accessory_name:, command:, stream: false)
78
+ command_argv = remote_command_argv(command)
79
+
60
80
  if stream && (target = live_accessory_target(accessory_name))
61
- execute_on_accessory_live(accessory_name: accessory_name, command: command, target: target)
81
+ execute_on_accessory_live(accessory_name: accessory_name, command_argv: command_argv, target: target)
62
82
  else
63
- capture_kamal(kamal_exec_argv(accessory_name, command), stream: stream)
83
+ capture_kamal(kamal_exec_argv(accessory_name, command_argv), stream: stream)
64
84
  end
65
85
  end
66
86
 
67
87
  def remote_version(accessory_name:)
68
- result = execute_on_accessory(accessory_name: accessory_name, command: 'kamal-backup version')
88
+ result = execute_on_accessory(accessory_name: accessory_name, command: %w[kamal-backup version])
69
89
  version = parse_version_line(result.stdout)
70
90
 
71
91
  raise ConfigurationError, "could not determine remote kamal-backup version from accessory #{accessory_name}" if version.empty?
@@ -103,7 +123,7 @@ module KamalBackup
103
123
  accessories.fetch(accessory_name) do
104
124
  accessories.fetch(accessory_name.to_sym) do
105
125
  raise ConfigurationError,
106
- "accessory #{accessory_name.inspect} is not defined in #{config_file || DEFAULT_CONFIG_FILE}"
126
+ "accessory #{accessory_name.inspect} is not defined in #{@config_file || DEFAULT_CONFIG_FILE}"
107
127
  end
108
128
  end
109
129
  end
@@ -170,10 +190,6 @@ module KamalBackup
170
190
  end
171
191
  end
172
192
 
173
- def fetch(hash, key)
174
- hash[key] || hash[key.to_s] || hash[key.to_sym]
175
- end
176
-
177
193
  def kamal_config_argv
178
194
  [
179
195
  *kamal_command,
@@ -193,7 +209,7 @@ module KamalBackup
193
209
  *(['--interactive'] if interactive),
194
210
  '--reuse',
195
211
  accessory_name,
196
- command
212
+ *kamal_remote_command_argv(command)
197
213
  ]
198
214
  end
199
215
 
@@ -248,17 +264,17 @@ module KamalBackup
248
264
  end
249
265
  end
250
266
 
251
- def execute_on_accessory_live(accessory_name:, command:, target:)
267
+ def execute_on_accessory_live(accessory_name:, command_argv:, target:)
252
268
  @stdout.puts('Launching command from existing container...')
253
269
 
254
270
  spec = CommandSpec.new(
255
- argv: ['docker', 'exec', target.fetch(:service_name), *Shellwords.split(command)],
271
+ argv: ['docker', 'exec', target.fetch(:service_name), *command_argv],
256
272
  host: target.fetch(:host)
257
273
  )
258
274
  context = Command.output&.command_start(spec, redactor: @redactor)
259
275
 
260
276
  result = capture_kamal(
261
- kamal_exec_argv(accessory_name, command, interactive: true),
277
+ kamal_exec_argv(accessory_name, command_argv, interactive: true),
262
278
  stream: true,
263
279
  log: false,
264
280
  stdout: filtered_interactive_stdout,
@@ -332,21 +348,6 @@ module KamalBackup
332
348
  service_with_version.delete_suffix(suffix)
333
349
  end
334
350
 
335
- class FilteringIO
336
- def initialize(io, &reject)
337
- @io = io
338
- @reject = reject
339
- end
340
-
341
- def print(output)
342
- @io.print(output) unless @reject.call(output.to_s)
343
- end
344
-
345
- def flush
346
- @io.flush if @io.respond_to?(:flush)
347
- end
348
- end
349
-
350
351
  def kamal_stream_env(stream)
351
352
  return {} unless stream
352
353
 
@@ -361,6 +362,17 @@ module KamalBackup
361
362
  [@stdout, @stderr].any? { |io| io.respond_to?(:tty?) && io.tty? }
362
363
  end
363
364
 
365
+ def remote_command_argv(command)
366
+ argv = command.is_a?(String) ? Shellwords.split(command) : Array(command).compact.map(&:to_s)
367
+ raise ArgumentError, 'remote command cannot be empty' if argv.empty?
368
+
369
+ argv
370
+ end
371
+
372
+ def kamal_remote_command_argv(command)
373
+ remote_command_argv(command).map { |arg| Shellwords.escape(arg) }
374
+ end
375
+
364
376
  def parse_version_line(output)
365
377
  output.to_s.lines.map(&:strip).reverse.find { |line| line.match?(VERSION_LINE_PATTERN) }.to_s
366
378
  end
@@ -3,9 +3,13 @@
3
3
  require 'erb'
4
4
  require 'uri'
5
5
  require 'yaml'
6
+ require_relative 'databases/base'
7
+ require_relative 'yaml_access'
6
8
 
7
9
  module KamalBackup
8
10
  class RailsApp
11
+ include YamlAccess
12
+
9
13
  DEVELOPMENT_ENV = 'development'
10
14
 
11
15
  def initialize(cwd:)
@@ -53,7 +57,7 @@ module KamalBackup
53
57
  'DATABASE_URL' => url.to_s
54
58
  }.compact
55
59
  else
56
- case normalize_adapter(fetch(config, :adapter))
60
+ case Databases.normalize_adapter(fetch(config, :adapter))
57
61
  when 'postgres'
58
62
  {
59
63
  'DATABASE_ADAPTER' => 'postgres',
@@ -107,7 +111,7 @@ module KamalBackup
107
111
  end
108
112
 
109
113
  def adapter_from_url(url)
110
- normalize_adapter(URI.parse(url.to_s).scheme)
114
+ Databases.normalize_adapter(URI.parse(url.to_s).scheme)
111
115
  rescue URI::InvalidURIError
112
116
  nil
113
117
  end
@@ -127,21 +131,6 @@ module KamalBackup
127
131
  raise ConfigurationError, "invalid YAML in #{path}: #{e.message}"
128
132
  end
129
133
 
130
- def fetch(hash, key)
131
- hash[key] || hash[key.to_s] || hash[key.to_sym]
132
- end
133
-
134
- def normalize_adapter(value)
135
- case value.to_s.downcase
136
- when 'postgres', 'postgresql'
137
- 'postgres'
138
- when 'mysql', 'mysql2', 'mariadb'
139
- 'mysql'
140
- when 'sqlite', 'sqlite3'
141
- 'sqlite'
142
- end
143
- end
144
-
145
134
  def database_config_path
146
135
  File.join(@cwd, 'config', 'database.yml')
147
136
  end