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