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.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/exe/kamal-backup +7 -6
- data/lib/kamal_backup/app.rb +330 -393
- data/lib/kamal_backup/cli/helpers.rb +298 -0
- data/lib/kamal_backup/cli.rb +73 -367
- data/lib/kamal_backup/command.rb +77 -258
- data/lib/kamal_backup/command_output.rb +189 -0
- data/lib/kamal_backup/config.rb +242 -624
- data/lib/kamal_backup/config_file.rb +376 -0
- data/lib/kamal_backup/databases/base.rb +28 -14
- data/lib/kamal_backup/databases/mysql.rb +68 -67
- data/lib/kamal_backup/databases/postgres.rb +59 -58
- data/lib/kamal_backup/databases/sqlite.rb +21 -20
- data/lib/kamal_backup/errors.rb +3 -1
- data/lib/kamal_backup/evidence.rb +61 -63
- data/lib/kamal_backup/kamal_bridge.rb +270 -254
- data/lib/kamal_backup/rails_app.rb +94 -104
- data/lib/kamal_backup/redactor.rb +18 -13
- data/lib/kamal_backup/restic.rb +207 -183
- data/lib/kamal_backup/scheduler.rb +17 -14
- data/lib/kamal_backup/schema.rb +2 -0
- data/lib/kamal_backup/version.rb +3 -1
- data/lib/kamal_backup/yaml_access.rb +13 -0
- data/lib/kamal_backup.rb +22 -17
- metadata +76 -2
|
@@ -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
|
-
|
|
2
|
-
|
|
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
|
|
22
|
+
when 'postgres'
|
|
10
23
|
Postgres.new(config, redactor: redactor)
|
|
11
|
-
when
|
|
24
|
+
when 'mysql'
|
|
12
25
|
Mysql.new(config, redactor: redactor)
|
|
13
|
-
when
|
|
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
|
-
[
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
10
|
+
'mysql'
|
|
9
11
|
end
|
|
10
12
|
|
|
11
13
|
def dump_extension
|
|
12
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
end
|
|
59
|
+
super
|
|
60
|
+
end
|
|
64
61
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
def password_env(connection)
|
|
118
|
+
connection[:password] ? { 'MYSQL_PWD' => connection[:password] } : {}
|
|
119
|
+
end
|
|
119
120
|
end
|
|
120
121
|
end
|
|
121
122
|
end
|