kamal-backup 0.3.0.beta21 → 0.3.0
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/exe/kamal-backup +7 -6
- data/lib/kamal_backup/app.rb +350 -356
- data/lib/kamal_backup/cli.rb +107 -111
- data/lib/kamal_backup/command.rb +165 -161
- data/lib/kamal_backup/config.rb +533 -511
- data/lib/kamal_backup/databases/base.rb +17 -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 +62 -61
- data/lib/kamal_backup/kamal_bridge.rb +254 -250
- data/lib/kamal_backup/rails_app.rb +102 -101
- data/lib/kamal_backup/redactor.rb +18 -13
- data/lib/kamal_backup/restic.rb +195 -167
- 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.rb +19 -17
- metadata +30 -2
data/lib/kamal_backup/app.rb
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
require_relative
|
|
8
|
-
require_relative
|
|
9
|
-
require_relative
|
|
10
|
-
require_relative
|
|
11
|
-
require_relative
|
|
12
|
-
require_relative
|
|
13
|
-
require_relative
|
|
14
|
-
require_relative
|
|
15
|
-
require_relative
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'time'
|
|
6
|
+
require 'tmpdir'
|
|
7
|
+
require_relative 'command'
|
|
8
|
+
require_relative 'config'
|
|
9
|
+
require_relative 'databases/base'
|
|
10
|
+
require_relative 'databases/mysql'
|
|
11
|
+
require_relative 'databases/postgres'
|
|
12
|
+
require_relative 'databases/sqlite'
|
|
13
|
+
require_relative 'evidence'
|
|
14
|
+
require_relative 'redactor'
|
|
15
|
+
require_relative 'restic'
|
|
16
|
+
require_relative 'scheduler'
|
|
17
|
+
require_relative 'schema'
|
|
16
18
|
|
|
17
19
|
module KamalBackup
|
|
18
20
|
class App
|
|
@@ -20,7 +22,8 @@ module KamalBackup
|
|
|
20
22
|
|
|
21
23
|
attr_reader :config, :redactor
|
|
22
24
|
|
|
23
|
-
def initialize(env: ENV, config: nil, redactor: nil, restic: nil, database: nil, evidence_class: Evidence,
|
|
25
|
+
def initialize(env: ENV, config: nil, redactor: nil, restic: nil, database: nil, evidence_class: Evidence,
|
|
26
|
+
scheduler_class: Scheduler)
|
|
24
27
|
@config = config || Config.new(env: env)
|
|
25
28
|
@redactor = redactor || Redactor.new(env: @config.env)
|
|
26
29
|
@restic = restic
|
|
@@ -38,15 +41,11 @@ module KamalBackup
|
|
|
38
41
|
|
|
39
42
|
restic.ensure_repository
|
|
40
43
|
databases.each { |database| database.backup(restic) }
|
|
41
|
-
restic.backup_paths(config.backup_paths, tags: [
|
|
44
|
+
restic.backup_paths(config.backup_paths, tags: ['type:files'])
|
|
42
45
|
|
|
43
|
-
if config.forget_after_backup?
|
|
44
|
-
restic.forget_after_success
|
|
45
|
-
end
|
|
46
|
+
restic.forget_after_success if config.forget_after_backup?
|
|
46
47
|
|
|
47
|
-
if config.check_after_backup?
|
|
48
|
-
restic.check
|
|
49
|
-
end
|
|
48
|
+
restic.check if config.check_after_backup?
|
|
50
49
|
|
|
51
50
|
backup_summary(started_at: started_at, finished_at: Time.now.utc).tap do |summary|
|
|
52
51
|
validate_fresh_backup_summary!(summary, started_at: started_at)
|
|
@@ -59,11 +58,11 @@ module KamalBackup
|
|
|
59
58
|
true
|
|
60
59
|
end
|
|
61
60
|
|
|
62
|
-
def restore_to_local_machine(snapshot =
|
|
61
|
+
def restore_to_local_machine(snapshot = 'latest')
|
|
63
62
|
validate_local_machine_restore
|
|
64
63
|
require_restic!
|
|
65
64
|
|
|
66
|
-
build_restore_result(
|
|
65
|
+
build_restore_result('local', snapshot) do |result|
|
|
67
66
|
databases.each { |adapter| validate_local_machine_database_target(adapter) }
|
|
68
67
|
database_results = perform_database_restores_to_current(snapshot)
|
|
69
68
|
file_results = perform_replacement_file_restores(snapshot, production_source: false)
|
|
@@ -71,22 +70,22 @@ module KamalBackup
|
|
|
71
70
|
end
|
|
72
71
|
end
|
|
73
72
|
|
|
74
|
-
def restore_to_production(snapshot =
|
|
73
|
+
def restore_to_production(snapshot = 'latest')
|
|
75
74
|
validate_production_restore
|
|
76
75
|
require_restic!
|
|
77
76
|
|
|
78
|
-
build_restore_result(
|
|
77
|
+
build_restore_result('production', snapshot) do |result|
|
|
79
78
|
database_results = perform_database_restores_to_current(snapshot)
|
|
80
79
|
file_results = perform_replacement_file_restores(snapshot, production_source: true)
|
|
81
80
|
assign_restore_results(result, database_results, file_results)
|
|
82
81
|
end
|
|
83
82
|
end
|
|
84
83
|
|
|
85
|
-
def drill_on_local_machine(snapshot =
|
|
84
|
+
def drill_on_local_machine(snapshot = 'latest', check_command: nil)
|
|
86
85
|
validate_local_machine_restore
|
|
87
86
|
require_restic!
|
|
88
87
|
|
|
89
|
-
run_drill(
|
|
88
|
+
run_drill('local', snapshot, check_command: check_command) do |result|
|
|
90
89
|
databases.each { |adapter| validate_local_machine_database_target(adapter) }
|
|
91
90
|
database_results = perform_database_restores_to_current(snapshot)
|
|
92
91
|
file_results = perform_replacement_file_restores(snapshot, production_source: false)
|
|
@@ -94,11 +93,12 @@ module KamalBackup
|
|
|
94
93
|
end
|
|
95
94
|
end
|
|
96
95
|
|
|
97
|
-
def drill_on_production(snapshot =
|
|
96
|
+
def drill_on_production(snapshot = 'latest', database_name: nil, sqlite_path: nil, file_target: '/restore/files',
|
|
97
|
+
check_command: nil)
|
|
98
98
|
validate_production_drill(file_target, database_name, sqlite_path)
|
|
99
99
|
require_restic!
|
|
100
100
|
|
|
101
|
-
run_drill(
|
|
101
|
+
run_drill('production', snapshot, check_command: check_command) do |result|
|
|
102
102
|
database_results = databases.map do |adapter|
|
|
103
103
|
perform_database_restore_to_scratch(
|
|
104
104
|
snapshot,
|
|
@@ -113,7 +113,7 @@ module KamalBackup
|
|
|
113
113
|
end
|
|
114
114
|
|
|
115
115
|
def drill_failed?(result)
|
|
116
|
-
result.fetch(:status) !=
|
|
116
|
+
result.fetch(:status) != 'ok'
|
|
117
117
|
rescue KeyError
|
|
118
118
|
true
|
|
119
119
|
end
|
|
@@ -148,397 +148,391 @@ module KamalBackup
|
|
|
148
148
|
end
|
|
149
149
|
|
|
150
150
|
private
|
|
151
|
-
def backup_due?(now)
|
|
152
|
-
due_at = next_backup_at
|
|
153
|
-
due_at.nil? || now >= due_at
|
|
154
|
-
end
|
|
155
151
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
reason: "not_due",
|
|
161
|
-
last_backup_at: last_backup_finished_at&.iso8601,
|
|
162
|
-
next_backup_at: next_backup_at&.iso8601,
|
|
163
|
-
force_command: "kamal-backup backup --force",
|
|
164
|
-
finished_at: now.iso8601
|
|
165
|
-
}
|
|
166
|
-
end
|
|
152
|
+
def backup_due?(now)
|
|
153
|
+
due_at = next_backup_at
|
|
154
|
+
due_at.nil? || now >= due_at
|
|
155
|
+
end
|
|
167
156
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
157
|
+
def skipped_backup_result(now)
|
|
158
|
+
{
|
|
159
|
+
kind: 'backup_result',
|
|
160
|
+
status: 'skipped',
|
|
161
|
+
reason: 'not_due',
|
|
162
|
+
last_backup_at: last_backup_finished_at&.iso8601,
|
|
163
|
+
next_backup_at: next_backup_at&.iso8601,
|
|
164
|
+
force_command: 'kamal-backup backup --force',
|
|
165
|
+
finished_at: now.iso8601
|
|
166
|
+
}
|
|
167
|
+
end
|
|
171
168
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
value ? Time.parse(value.to_s).utc : nil
|
|
176
|
-
rescue ArgumentError
|
|
177
|
-
nil
|
|
178
|
-
end
|
|
179
|
-
end
|
|
169
|
+
def next_backup_at
|
|
170
|
+
last_backup_finished_at + config.backup_schedule_seconds if last_backup_finished_at
|
|
171
|
+
end
|
|
180
172
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
173
|
+
def last_backup_finished_at
|
|
174
|
+
@last_backup_finished_at ||= begin
|
|
175
|
+
value = last_backup_record['finished_at'] || last_backup_record['last_backup_at']
|
|
176
|
+
value ? Time.parse(value.to_s).utc : nil
|
|
177
|
+
rescue ArgumentError
|
|
178
|
+
nil
|
|
187
179
|
end
|
|
180
|
+
end
|
|
188
181
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
scope: scope,
|
|
195
|
-
requested_snapshot: snapshot,
|
|
196
|
-
started_at: started_at.iso8601,
|
|
197
|
-
finished_at: nil,
|
|
198
|
-
error: nil,
|
|
199
|
-
database: nil,
|
|
200
|
-
databases: [],
|
|
201
|
-
files: nil,
|
|
202
|
-
paths: nil
|
|
203
|
-
)
|
|
204
|
-
yield(result)
|
|
205
|
-
result[:finished_at] = Time.now.utc.iso8601
|
|
206
|
-
result
|
|
182
|
+
def last_backup_record
|
|
183
|
+
@last_backup_record ||= begin
|
|
184
|
+
JSON.parse(File.read(config.last_backup_path))
|
|
185
|
+
rescue JSON::ParserError, SystemCallError
|
|
186
|
+
{}
|
|
207
187
|
end
|
|
188
|
+
end
|
|
208
189
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
190
|
+
def build_restore_result(scope, snapshot)
|
|
191
|
+
started_at = Time.now.utc
|
|
192
|
+
result = Schema.record(
|
|
193
|
+
kind: 'restore_result',
|
|
194
|
+
status: 'ok',
|
|
195
|
+
scope: scope,
|
|
196
|
+
requested_snapshot: snapshot,
|
|
197
|
+
started_at: started_at.iso8601,
|
|
198
|
+
finished_at: nil,
|
|
199
|
+
error: nil,
|
|
200
|
+
database: nil,
|
|
201
|
+
databases: [],
|
|
202
|
+
files: nil,
|
|
203
|
+
paths: nil
|
|
204
|
+
)
|
|
205
|
+
yield(result)
|
|
206
|
+
result[:finished_at] = Time.now.utc.iso8601
|
|
207
|
+
result
|
|
208
|
+
end
|
|
226
209
|
|
|
227
|
-
|
|
228
|
-
|
|
210
|
+
def run_drill(scope, snapshot, check_command:)
|
|
211
|
+
started_at = Time.now.utc
|
|
212
|
+
result = Schema.record(
|
|
213
|
+
kind: 'drill_result',
|
|
214
|
+
status: 'ok',
|
|
215
|
+
scope: scope,
|
|
216
|
+
operator: drill_operator,
|
|
217
|
+
requested_snapshot: snapshot,
|
|
218
|
+
started_at: started_at.iso8601,
|
|
219
|
+
finished_at: nil,
|
|
220
|
+
error: nil,
|
|
221
|
+
database: nil,
|
|
222
|
+
databases: [],
|
|
223
|
+
files: nil,
|
|
224
|
+
paths: nil,
|
|
225
|
+
check: nil
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
begin
|
|
229
|
+
yield(result)
|
|
229
230
|
|
|
230
|
-
|
|
231
|
-
|
|
231
|
+
if check_command
|
|
232
|
+
result[:check] = run_drill_check(check_command)
|
|
232
233
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
end
|
|
234
|
+
if result[:check][:status] == 'failed'
|
|
235
|
+
result[:status] = 'failed'
|
|
236
|
+
result[:error] = result[:check][:error]
|
|
237
237
|
end
|
|
238
|
-
rescue StandardError => e
|
|
239
|
-
result[:status] = "failed"
|
|
240
|
-
result[:error] = redactor.redact_string(e.message)
|
|
241
|
-
ensure
|
|
242
|
-
result[:finished_at] = Time.now.utc.iso8601
|
|
243
|
-
write_last_restore_drill(result)
|
|
244
238
|
end
|
|
245
|
-
|
|
246
|
-
result
|
|
239
|
+
rescue StandardError => e
|
|
240
|
+
result[:status] = 'failed'
|
|
241
|
+
result[:error] = redactor.redact_string(e.message)
|
|
242
|
+
ensure
|
|
243
|
+
result[:finished_at] = Time.now.utc.iso8601
|
|
244
|
+
write_last_restore_drill(result)
|
|
247
245
|
end
|
|
248
246
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
filename = restic.database_file(resolved_snapshot, adapter.adapter_name, database_name: database_config_name(adapter))
|
|
247
|
+
result
|
|
248
|
+
end
|
|
252
249
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
raise ConfigurationError, "could not find database backup file in snapshot #{resolved_snapshot}"
|
|
258
|
-
end
|
|
259
|
-
end
|
|
250
|
+
def perform_database_restore_to_current(snapshot, adapter:)
|
|
251
|
+
resolved_snapshot = resolve_snapshot(snapshot, tags: database_snapshot_tags(adapter))
|
|
252
|
+
filename = restic.database_file(resolved_snapshot, adapter.adapter_name,
|
|
253
|
+
database_name: database_config_name(adapter))
|
|
260
254
|
|
|
261
|
-
|
|
262
|
-
target = scratch_database_target(adapter, database_name, sqlite_path)
|
|
263
|
-
resolved_snapshot = resolve_snapshot(snapshot, tags: database_snapshot_tags(adapter))
|
|
264
|
-
filename = restic.database_file(resolved_snapshot, adapter.adapter_name, database_name: database_config_name(adapter))
|
|
255
|
+
raise ConfigurationError, "could not find database backup file in snapshot #{resolved_snapshot}" unless filename
|
|
265
256
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
else
|
|
270
|
-
raise ConfigurationError, "could not find database backup file in snapshot #{resolved_snapshot}"
|
|
271
|
-
end
|
|
272
|
-
end
|
|
257
|
+
adapter.restore_to_current(restic, resolved_snapshot, filename)
|
|
258
|
+
summarize_database_restore(adapter, resolved_snapshot, filename, adapter.current_target_identifier)
|
|
259
|
+
end
|
|
273
260
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
261
|
+
def perform_database_restore_to_scratch(snapshot, adapter:, database_name:, sqlite_path:)
|
|
262
|
+
target = scratch_database_target(adapter, database_name, sqlite_path)
|
|
263
|
+
resolved_snapshot = resolve_snapshot(snapshot, tags: database_snapshot_tags(adapter))
|
|
264
|
+
filename = restic.database_file(resolved_snapshot, adapter.adapter_name,
|
|
265
|
+
database_name: database_config_name(adapter))
|
|
277
266
|
|
|
278
|
-
|
|
279
|
-
source_paths = production_source ? config.backup_paths : config.local_restore_source_paths
|
|
280
|
-
[
|
|
281
|
-
perform_replacement_file_restore(
|
|
282
|
-
snapshot,
|
|
283
|
-
source_paths: source_paths,
|
|
284
|
-
target_paths: config.backup_paths
|
|
285
|
-
)
|
|
286
|
-
]
|
|
287
|
-
end
|
|
267
|
+
raise ConfigurationError, "could not find database backup file in snapshot #{resolved_snapshot}" unless filename
|
|
288
268
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
result[:paths] = file_results.first
|
|
293
|
-
result[:files] = file_results.size == 1 ? file_results.first : file_results
|
|
294
|
-
end
|
|
269
|
+
adapter.restore_to_scratch(restic, resolved_snapshot, filename, target: target)
|
|
270
|
+
summarize_database_restore(adapter, resolved_snapshot, filename, adapter.scratch_target_identifier(target))
|
|
271
|
+
end
|
|
295
272
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
status: "ok",
|
|
300
|
-
started_at: started_at.iso8601,
|
|
301
|
-
finished_at: finished_at.iso8601,
|
|
302
|
-
databases: databases.map { |adapter| backup_snapshot_summary(adapter) },
|
|
303
|
-
files: backup_file_snapshot_summary
|
|
304
|
-
}
|
|
305
|
-
end
|
|
273
|
+
def perform_database_restores_to_current(snapshot)
|
|
274
|
+
databases.map { |adapter| perform_database_restore_to_current(snapshot, adapter: adapter) }
|
|
275
|
+
end
|
|
306
276
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
277
|
+
def perform_replacement_file_restores(snapshot, production_source:)
|
|
278
|
+
source_paths = production_source ? config.backup_paths : config.local_restore_source_paths
|
|
279
|
+
[
|
|
280
|
+
perform_replacement_file_restore(
|
|
281
|
+
snapshot,
|
|
282
|
+
source_paths: source_paths,
|
|
283
|
+
target_paths: config.backup_paths
|
|
312
284
|
)
|
|
313
|
-
|
|
285
|
+
]
|
|
286
|
+
end
|
|
314
287
|
|
|
315
|
-
|
|
316
|
-
|
|
288
|
+
def assign_restore_results(result, database_results, file_results)
|
|
289
|
+
result[:databases] = database_results
|
|
290
|
+
result[:database] = database_results.first
|
|
291
|
+
result[:paths] = file_results.first
|
|
292
|
+
result[:files] = file_results.size == 1 ? file_results.first : file_results
|
|
293
|
+
end
|
|
317
294
|
|
|
318
|
-
|
|
319
|
-
|
|
295
|
+
def backup_summary(started_at:, finished_at:)
|
|
296
|
+
{
|
|
297
|
+
kind: 'backup_result',
|
|
298
|
+
status: 'ok',
|
|
299
|
+
started_at: started_at.iso8601,
|
|
300
|
+
finished_at: finished_at.iso8601,
|
|
301
|
+
databases: databases.map { |adapter| backup_snapshot_summary(adapter) },
|
|
302
|
+
files: backup_file_snapshot_summary
|
|
303
|
+
}
|
|
304
|
+
end
|
|
320
305
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
306
|
+
def backup_snapshot_summary(adapter)
|
|
307
|
+
snapshot = restic.latest_snapshot(tags: database_snapshot_tags(adapter))
|
|
308
|
+
snapshot_summary(snapshot).merge(
|
|
309
|
+
database: database_config_name(adapter),
|
|
310
|
+
adapter: adapter.adapter_name
|
|
311
|
+
)
|
|
312
|
+
end
|
|
327
313
|
|
|
328
|
-
|
|
329
|
-
|
|
314
|
+
def backup_file_snapshot_summary
|
|
315
|
+
return nil if config.backup_paths.empty?
|
|
330
316
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
raise ConfigurationError, "backup did not create a fresh database snapshot for #{names}"
|
|
334
|
-
end
|
|
317
|
+
snapshot_summary(restic.latest_snapshot(tags: ['type:files']))
|
|
318
|
+
end
|
|
335
319
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
320
|
+
def snapshot_summary(snapshot)
|
|
321
|
+
{
|
|
322
|
+
snapshot: snapshot && (snapshot['short_id'] || snapshot['id']),
|
|
323
|
+
time: snapshot && snapshot['time']
|
|
324
|
+
}
|
|
325
|
+
end
|
|
341
326
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
snapshot_time >= started_at - FRESH_BACKUP_GRACE_SECONDS
|
|
345
|
-
rescue ArgumentError
|
|
346
|
-
false
|
|
347
|
-
end
|
|
327
|
+
def validate_fresh_backup_summary!(summary, started_at:)
|
|
328
|
+
stale_databases = summary.fetch(:databases).reject { |entry| fresh_snapshot?(entry, started_at) }
|
|
348
329
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
@last_backup_record = result.transform_keys(&:to_s)
|
|
353
|
-
@last_backup_finished_at = Time.parse(result.fetch(:finished_at)).utc
|
|
354
|
-
rescue SystemCallError, ArgumentError
|
|
355
|
-
nil
|
|
330
|
+
unless stale_databases.empty?
|
|
331
|
+
names = stale_databases.map { |entry| entry.fetch(:database) }.join(', ')
|
|
332
|
+
raise ConfigurationError, "backup did not create a fresh database snapshot for #{names}"
|
|
356
333
|
end
|
|
357
334
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
end
|
|
335
|
+
files = summary[:files]
|
|
336
|
+
return unless files && !fresh_snapshot?(files, started_at)
|
|
361
337
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
adapter.config.database_name
|
|
365
|
-
else
|
|
366
|
-
config.database_name
|
|
367
|
-
end
|
|
368
|
-
end
|
|
338
|
+
raise ConfigurationError, 'backup did not create a fresh file snapshot'
|
|
339
|
+
end
|
|
369
340
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
341
|
+
def fresh_snapshot?(entry, started_at)
|
|
342
|
+
snapshot_time = Time.parse(entry[:time].to_s)
|
|
343
|
+
snapshot_time >= started_at - FRESH_BACKUP_GRACE_SECONDS
|
|
344
|
+
rescue ArgumentError
|
|
345
|
+
false
|
|
346
|
+
end
|
|
374
347
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
348
|
+
def write_last_backup(result)
|
|
349
|
+
FileUtils.mkdir_p(config.state_dir)
|
|
350
|
+
File.write(config.last_backup_path, JSON.pretty_generate(result))
|
|
351
|
+
@last_backup_record = result.transform_keys(&:to_s)
|
|
352
|
+
@last_backup_finished_at = Time.parse(result.fetch(:finished_at)).utc
|
|
353
|
+
rescue SystemCallError, ArgumentError
|
|
354
|
+
nil
|
|
355
|
+
end
|
|
380
356
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
restic.restore_snapshot(resolved_snapshot, stage_dir)
|
|
385
|
-
replace_target_paths(stage_dir, source_paths: source_paths, target_paths: target_paths)
|
|
386
|
-
end
|
|
357
|
+
def database_snapshot_tags(adapter)
|
|
358
|
+
['type:database', "database:#{database_config_name(adapter)}", "adapter:#{adapter.adapter_name}"]
|
|
359
|
+
end
|
|
387
360
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
361
|
+
def database_config_name(adapter)
|
|
362
|
+
if adapter.respond_to?(:config) && adapter.config.respond_to?(:database_name)
|
|
363
|
+
adapter.config.database_name
|
|
364
|
+
else
|
|
365
|
+
config.database_name
|
|
393
366
|
end
|
|
367
|
+
end
|
|
394
368
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
end
|
|
369
|
+
def perform_file_restore(snapshot, target:)
|
|
370
|
+
resolved_snapshot = resolve_snapshot(snapshot, tags: ['type:files'])
|
|
371
|
+
validated_target = config.validate_file_restore_target(target)
|
|
372
|
+
restic.restore_snapshot(resolved_snapshot, validated_target)
|
|
400
373
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
target
|
|
374
|
+
{
|
|
375
|
+
snapshot: resolved_snapshot,
|
|
376
|
+
target: validated_target
|
|
377
|
+
}
|
|
378
|
+
end
|
|
404
379
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
raise ConfigurationError, "restored file snapshot is missing #{source_path}"
|
|
411
|
-
end
|
|
380
|
+
def perform_replacement_file_restore(snapshot, source_paths:, target_paths:)
|
|
381
|
+
resolved_snapshot = resolve_snapshot(snapshot, tags: ['type:files'])
|
|
382
|
+
Dir.mktmpdir('kamal-backup-restore-') do |stage_dir|
|
|
383
|
+
restic.restore_snapshot(resolved_snapshot, stage_dir)
|
|
384
|
+
replace_target_paths(stage_dir, source_paths: source_paths, target_paths: target_paths)
|
|
412
385
|
end
|
|
413
386
|
|
|
414
|
-
|
|
415
|
-
|
|
387
|
+
{
|
|
388
|
+
snapshot: resolved_snapshot,
|
|
389
|
+
source_paths: source_paths,
|
|
390
|
+
target_paths: target_paths.map { |path| File.expand_path(path) }
|
|
391
|
+
}
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def replace_target_paths(stage_dir, source_paths:, target_paths:)
|
|
395
|
+
source_paths.zip(target_paths).each do |source_path, target_path|
|
|
396
|
+
replace_target_path(stage_dir, source_path, target_path)
|
|
416
397
|
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def replace_target_path(stage_dir, source_path, target_path)
|
|
401
|
+
source = staged_backup_path(stage_dir, source_path)
|
|
402
|
+
target = File.expand_path(target_path)
|
|
403
|
+
|
|
404
|
+
raise ConfigurationError, "restored file snapshot is missing #{source_path}" unless File.exist?(source)
|
|
405
|
+
|
|
406
|
+
FileUtils.rm_rf(target)
|
|
407
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
408
|
+
FileUtils.mv(source, target)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def staged_backup_path(stage_dir, path)
|
|
412
|
+
File.join(stage_dir, path.to_s.sub(%r{\A/+}, ''))
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def summarize_database_restore(adapter, snapshot, filename, target)
|
|
416
|
+
{
|
|
417
|
+
snapshot: snapshot,
|
|
418
|
+
adapter: adapter.adapter_name,
|
|
419
|
+
filename: filename,
|
|
420
|
+
target: redactor.redact_string(target.to_s)
|
|
421
|
+
}
|
|
422
|
+
end
|
|
417
423
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
424
|
+
def scratch_database_target(adapter, database_name, sqlite_path)
|
|
425
|
+
case adapter.adapter_name
|
|
426
|
+
when 'sqlite'
|
|
427
|
+
target = sqlite_path || raise(ConfigurationError, 'scratch SQLite path is required')
|
|
428
|
+
databases.size > 1 ? File.join(target, "#{database_config_name(adapter)}.sqlite3") : target
|
|
429
|
+
else
|
|
430
|
+
target = database_name || raise(ConfigurationError, 'scratch database name is required')
|
|
431
|
+
databases.size > 1 ? "#{target}_#{database_config_name(adapter)}" : target
|
|
425
432
|
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def validate_local_machine_restore
|
|
436
|
+
config.validate_restic
|
|
437
|
+
config.validate_database_backup
|
|
438
|
+
config.validate_local_machine_restore
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def validate_production_restore
|
|
442
|
+
config.validate_restic
|
|
443
|
+
config.validate_database_backup
|
|
444
|
+
config.validate_backup_paths
|
|
445
|
+
end
|
|
426
446
|
|
|
427
|
-
|
|
447
|
+
def validate_production_drill(file_target, database_name, sqlite_path)
|
|
448
|
+
config.validate_restic
|
|
449
|
+
config.validate_database_backup
|
|
450
|
+
config.validate_file_restore_target(file_target)
|
|
451
|
+
|
|
452
|
+
databases.each do |adapter|
|
|
428
453
|
case adapter.adapter_name
|
|
429
|
-
when
|
|
430
|
-
|
|
431
|
-
databases.size > 1 ? File.join(target, "#{database_config_name(adapter)}.sqlite3") : target
|
|
454
|
+
when 'sqlite'
|
|
455
|
+
raise ConfigurationError, 'scratch SQLite path is required' if sqlite_path.to_s.strip.empty?
|
|
432
456
|
else
|
|
433
|
-
|
|
434
|
-
databases.size > 1 ? "#{target}_#{database_config_name(adapter)}" : target
|
|
457
|
+
raise ConfigurationError, 'scratch database name is required' if database_name.to_s.strip.empty?
|
|
435
458
|
end
|
|
436
459
|
end
|
|
460
|
+
end
|
|
437
461
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
config.validate_local_machine_restore
|
|
442
|
-
end
|
|
443
|
-
|
|
444
|
-
def validate_production_restore
|
|
445
|
-
config.validate_restic
|
|
446
|
-
config.validate_database_backup
|
|
447
|
-
config.validate_backup_paths
|
|
448
|
-
end
|
|
462
|
+
def validate_local_machine_database_target(adapter)
|
|
463
|
+
config.validate_local_database_restore_target(adapter.current_target_identifier)
|
|
464
|
+
end
|
|
449
465
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
config.validate_file_restore_target(file_target)
|
|
454
|
-
|
|
455
|
-
databases.each do |adapter|
|
|
456
|
-
case adapter.adapter_name
|
|
457
|
-
when "sqlite"
|
|
458
|
-
raise ConfigurationError, "scratch SQLite path is required" if sqlite_path.to_s.strip.empty?
|
|
459
|
-
else
|
|
460
|
-
raise ConfigurationError, "scratch database name is required" if database_name.to_s.strip.empty?
|
|
461
|
-
end
|
|
462
|
-
end
|
|
463
|
-
end
|
|
466
|
+
def require_restic!
|
|
467
|
+
return unless using_builtin_restic?
|
|
468
|
+
return if Command.available?('restic')
|
|
464
469
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
470
|
+
raise ConfigurationError,
|
|
471
|
+
'restic is required on PATH for commands that run on this machine. Install restic locally and try again.'
|
|
472
|
+
end
|
|
468
473
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
474
|
+
def run_drill_check(command)
|
|
475
|
+
result = Command.capture(
|
|
476
|
+
CommandSpec.new(argv: ['sh', '-lc', command]),
|
|
477
|
+
redactor: redactor
|
|
478
|
+
)
|
|
479
|
+
output = result.stdout.empty? ? result.stderr : result.stdout
|
|
480
|
+
|
|
481
|
+
{
|
|
482
|
+
status: 'ok',
|
|
483
|
+
command: redactor.redact_string(command),
|
|
484
|
+
output: redactor.redact_string(output.strip)
|
|
485
|
+
}
|
|
486
|
+
rescue CommandError => e
|
|
487
|
+
{
|
|
488
|
+
status: 'failed',
|
|
489
|
+
command: redactor.redact_string(command),
|
|
490
|
+
error: redactor.redact_string(e.message)
|
|
491
|
+
}
|
|
492
|
+
end
|
|
472
493
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
494
|
+
def write_last_restore_drill(payload)
|
|
495
|
+
FileUtils.mkdir_p(config.state_dir)
|
|
496
|
+
File.write(config.last_restore_drill_path, JSON.pretty_generate(payload))
|
|
497
|
+
rescue SystemCallError
|
|
498
|
+
nil
|
|
499
|
+
end
|
|
476
500
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
redactor: redactor
|
|
481
|
-
)
|
|
482
|
-
output = result.stdout.empty? ? result.stderr : result.stdout
|
|
483
|
-
|
|
484
|
-
{
|
|
485
|
-
status: "ok",
|
|
486
|
-
command: redactor.redact_string(command),
|
|
487
|
-
output: redactor.redact_string(output.strip)
|
|
488
|
-
}
|
|
489
|
-
rescue CommandError => e
|
|
490
|
-
{
|
|
491
|
-
status: "failed",
|
|
492
|
-
command: redactor.redact_string(command),
|
|
493
|
-
error: redactor.redact_string(e.message)
|
|
494
|
-
}
|
|
495
|
-
end
|
|
501
|
+
def drill_operator
|
|
502
|
+
config.value('USER') || config.value('USERNAME')
|
|
503
|
+
end
|
|
496
504
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
rescue SystemCallError
|
|
501
|
-
nil
|
|
502
|
-
end
|
|
505
|
+
def restic
|
|
506
|
+
@restic ||= Restic.new(config, redactor: redactor)
|
|
507
|
+
end
|
|
503
508
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
509
|
+
def using_builtin_restic?
|
|
510
|
+
@restic.nil? || @restic.is_a?(Restic)
|
|
511
|
+
end
|
|
507
512
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
513
|
+
def database
|
|
514
|
+
databases.first
|
|
515
|
+
end
|
|
511
516
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
517
|
+
def databases
|
|
518
|
+
@databases ||= if @database
|
|
519
|
+
Array(@database)
|
|
520
|
+
else
|
|
521
|
+
config.databases.map { |database_config| Databases::Base.build(database_config, redactor: redactor) }
|
|
522
|
+
end
|
|
523
|
+
end
|
|
515
524
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
525
|
+
def resolve_snapshot(argument, tags:)
|
|
526
|
+
if argument == 'latest'
|
|
527
|
+
snapshot = restic.latest_snapshot(tags: tags)
|
|
519
528
|
|
|
520
|
-
|
|
521
|
-
@databases ||= begin
|
|
522
|
-
if @database
|
|
523
|
-
Array(@database)
|
|
524
|
-
else
|
|
525
|
-
config.databases.map { |database_config| Databases::Base.build(database_config, redactor: redactor) }
|
|
526
|
-
end
|
|
527
|
-
end
|
|
528
|
-
end
|
|
529
|
+
raise ConfigurationError, "no restic snapshot found for #{tags.join(', ')}" unless snapshot
|
|
529
530
|
|
|
530
|
-
|
|
531
|
-
if argument == "latest"
|
|
532
|
-
snapshot = restic.latest_snapshot(tags: tags)
|
|
531
|
+
snapshot['short_id'] || snapshot['id']
|
|
533
532
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
else
|
|
537
|
-
raise ConfigurationError, "no restic snapshot found for #{tags.join(", ")}"
|
|
538
|
-
end
|
|
539
|
-
else
|
|
540
|
-
argument
|
|
541
|
-
end
|
|
533
|
+
else
|
|
534
|
+
argument
|
|
542
535
|
end
|
|
536
|
+
end
|
|
543
537
|
end
|
|
544
538
|
end
|