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.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/lib/kamal_backup/app.rb +145 -202
- data/lib/kamal_backup/cli/helpers.rb +298 -0
- data/lib/kamal_backup/cli.rb +4 -294
- data/lib/kamal_backup/command.rb +0 -185
- data/lib/kamal_backup/command_output.rb +189 -0
- data/lib/kamal_backup/config.rb +77 -481
- data/lib/kamal_backup/config_file.rb +376 -0
- data/lib/kamal_backup/databases/base.rb +11 -0
- data/lib/kamal_backup/databases/postgres.rb +3 -3
- data/lib/kamal_backup/evidence.rb +0 -3
- data/lib/kamal_backup/kamal_bridge.rb +39 -27
- data/lib/kamal_backup/rails_app.rb +6 -17
- data/lib/kamal_backup/restic.rb +41 -45
- data/lib/kamal_backup/version.rb +1 -1
- data/lib/kamal_backup/yaml_access.rb +13 -0
- data/lib/kamal_backup.rb +3 -0
- metadata +48 -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
|
|
@@ -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')
|
|
73
|
-
|
|
74
|
-
|
|
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,
|
|
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,
|
|
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:
|
|
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:,
|
|
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), *
|
|
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,
|
|
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
|