kamal-backup 0.3.0.beta8 → 0.3.0.beta10

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d920c58d8ff83794df465f0549d8599ea6a5e815b800221fa850ce024241abfa
4
- data.tar.gz: feb8374a19862349d1fb0b3d55e3e697b45df66d58fc0c0f63ddba4767ed01f9
3
+ metadata.gz: c534b081ef41f75a8d10e72ae0fcbaa7b81ad5a0f2cbb3a12c281353d74ab69b
4
+ data.tar.gz: 6bd3b48d5573ab6a1e19ec1452584b7d44fc62e852edb7c202e2191d3bac6743
5
5
  SHA512:
6
- metadata.gz: 9e36489f2557dca0695be87b779a9b20f96148b141e67a9422bc8933c925ea68a17a73d221c56f4a51585258194752f0db4f0b6fed4a4845783334e6dc0d8e0e
7
- data.tar.gz: f8bb63eb69f7da8bdfcc001e07c95495ac60fa6996eb4b4188cfb2a8bc453979176985703f897c8e5ac4a9b02faec5cf3d76aad15862b0c964d6c961c2910556
6
+ metadata.gz: fdef6636e464308da24e9bb11aa64d8e68535fc9a8fc261a4dce02dc7e0d0bd26f20320f9dfede598085d780764310b0b3a7ad71ba3fe8d48e61d5c756b3597a
7
+ data.tar.gz: de0667fae6cb0ae276f1b4511ea1c63ffe8655523648a0e3f1e40d6872e53ba2823b4d987d4e651ace4d47710d516d17dc32f9e269e9bd20115501a533f793be
data/README.md CHANGED
@@ -112,6 +112,8 @@ bundle exec kamal-backup check
112
112
  bundle exec kamal-backup evidence
113
113
  ```
114
114
 
115
+ `backup` respects `backup.schedule` and skips when the latest backup is still current. Use `bundle exec kamal-backup backup --force` when you deliberately want an immediate snapshot.
116
+
115
117
  ## What you get
116
118
 
117
119
  - **Scheduled backups:** the accessory runs continuously and backs up on `backup.schedule`.
@@ -16,6 +16,8 @@ require_relative "schema"
16
16
 
17
17
  module KamalBackup
18
18
  class App
19
+ FRESH_BACKUP_GRACE_SECONDS = 5
20
+
19
21
  attr_reader :config, :redactor
20
22
 
21
23
  def initialize(env: ENV, config: nil, redactor: nil, restic: nil, database: nil, evidence_class: Evidence, scheduler_class: Scheduler)
@@ -27,8 +29,11 @@ module KamalBackup
27
29
  @scheduler_class = scheduler_class
28
30
  end
29
31
 
30
- def backup
32
+ def backup(force: false)
33
+ started_at = Time.now.utc
31
34
  config.validate_backup
35
+ return skipped_backup_result(started_at) unless force || backup_due?(started_at)
36
+
32
37
  require_restic!
33
38
 
34
39
  restic.ensure_repository
@@ -43,7 +48,10 @@ module KamalBackup
43
48
  restic.check
44
49
  end
45
50
 
46
- true
51
+ backup_summary(started_at: started_at, finished_at: Time.now.utc).tap do |summary|
52
+ validate_fresh_backup_summary!(summary, started_at: started_at)
53
+ write_last_backup(summary)
54
+ end
47
55
  end
48
56
 
49
57
  def validate(check_files: true)
@@ -130,10 +138,48 @@ module KamalBackup
130
138
 
131
139
  def schedule
132
140
  config.validate_backup
133
- @scheduler_class.new(config) { backup }.run
141
+ @scheduler_class.new(config) { backup(force: true) }.run
134
142
  end
135
143
 
136
144
  private
145
+ def backup_due?(now)
146
+ due_at = next_backup_at
147
+ due_at.nil? || now >= due_at
148
+ end
149
+
150
+ def skipped_backup_result(now)
151
+ {
152
+ kind: "backup_result",
153
+ status: "skipped",
154
+ reason: "not_due",
155
+ last_backup_at: last_backup_finished_at&.iso8601,
156
+ next_backup_at: next_backup_at&.iso8601,
157
+ force_command: "kamal-backup backup --force",
158
+ finished_at: now.iso8601
159
+ }
160
+ end
161
+
162
+ def next_backup_at
163
+ last_backup_finished_at + config.backup_schedule_seconds if last_backup_finished_at
164
+ end
165
+
166
+ def last_backup_finished_at
167
+ @last_backup_finished_at ||= begin
168
+ value = last_backup_record["finished_at"] || last_backup_record["last_backup_at"]
169
+ value ? Time.parse(value.to_s).utc : nil
170
+ rescue ArgumentError
171
+ nil
172
+ end
173
+ end
174
+
175
+ def last_backup_record
176
+ @last_backup_record ||= begin
177
+ JSON.parse(File.read(config.last_backup_path))
178
+ rescue JSON::ParserError, SystemCallError
179
+ {}
180
+ end
181
+ end
182
+
137
183
  def build_restore_result(scope, snapshot)
138
184
  started_at = Time.now.utc
139
185
  result = Schema.record(
@@ -241,6 +287,68 @@ module KamalBackup
241
287
  result[:files] = file_results.size == 1 ? file_results.first : file_results
242
288
  end
243
289
 
290
+ def backup_summary(started_at:, finished_at:)
291
+ {
292
+ kind: "backup_result",
293
+ status: "ok",
294
+ started_at: started_at.iso8601,
295
+ finished_at: finished_at.iso8601,
296
+ databases: databases.map { |adapter| backup_snapshot_summary(adapter) },
297
+ files: backup_file_snapshot_summary
298
+ }
299
+ end
300
+
301
+ def backup_snapshot_summary(adapter)
302
+ snapshot = restic.latest_snapshot(tags: database_snapshot_tags(adapter))
303
+ snapshot_summary(snapshot).merge(
304
+ database: database_config_name(adapter),
305
+ adapter: adapter.adapter_name
306
+ )
307
+ end
308
+
309
+ def backup_file_snapshot_summary
310
+ return nil if config.backup_paths.empty?
311
+
312
+ snapshot_summary(restic.latest_snapshot(tags: ["type:files"]))
313
+ end
314
+
315
+ def snapshot_summary(snapshot)
316
+ {
317
+ snapshot: snapshot && (snapshot["short_id"] || snapshot["id"]),
318
+ time: snapshot && snapshot["time"]
319
+ }
320
+ end
321
+
322
+ def validate_fresh_backup_summary!(summary, started_at:)
323
+ stale_databases = summary.fetch(:databases).reject { |entry| fresh_snapshot?(entry, started_at) }
324
+
325
+ unless stale_databases.empty?
326
+ names = stale_databases.map { |entry| entry.fetch(:database) }.join(", ")
327
+ raise ConfigurationError, "backup did not create a fresh database snapshot for #{names}"
328
+ end
329
+
330
+ files = summary[:files]
331
+ if files && !fresh_snapshot?(files, started_at)
332
+ raise ConfigurationError, "backup did not create a fresh file snapshot"
333
+ end
334
+ end
335
+
336
+ def fresh_snapshot?(entry, started_at)
337
+ snapshot_time = Time.parse(entry[:time].to_s)
338
+ snapshot_time >= started_at - FRESH_BACKUP_GRACE_SECONDS
339
+ rescue ArgumentError
340
+ false
341
+ end
342
+
343
+ def write_last_backup(result)
344
+ FileUtils.mkdir_p(config.state_dir)
345
+ File.write(config.last_backup_path, JSON.pretty_generate(result))
346
+ @last_backup_record = result.transform_keys(&:to_s)
347
+ @last_backup_finished_at = Time.parse(result.fetch(:finished_at)).utc
348
+ rescue SystemCallError, ArgumentError
349
+ nil
350
+ end
351
+
244
352
  def database_snapshot_tags(adapter)
245
353
  ["type:database", "database:#{database_config_name(adapter)}", "adapter:#{adapter.adapter_name}"]
246
354
  end
@@ -138,6 +138,26 @@ module KamalBackup
138
138
  puts("fix: #{status_output.decorate(accessory_reboot_command, :yellow, :bold)}") if status == "out of sync"
139
139
  end
140
140
 
141
+ def print_backup_result(result)
142
+ return unless result.is_a?(Hash)
143
+
144
+ if result[:status] == "skipped"
145
+ puts("No backup due. Last backup finished at #{result.fetch(:last_backup_at)}.")
146
+ puts("Next backup is due at #{result.fetch(:next_backup_at)}.")
147
+ puts("Run `#{result.fetch(:force_command)}` to force a backup now.")
148
+ return
149
+ end
150
+
151
+ puts("Backup completed at #{result.fetch(:finished_at)}")
152
+ result.fetch(:databases).each do |database|
153
+ puts("database #{database.fetch(:database)}: #{database.fetch(:snapshot)} at #{database.fetch(:time)}")
154
+ end
155
+
156
+ if files = result[:files]
157
+ puts("files: #{files.fetch(:snapshot)} at #{files.fetch(:time)}")
158
+ end
159
+ end
160
+
141
161
  def validate_deploy_config
142
162
  config = Config.new(
143
163
  env: bridge.accessory_environment(accessory_name: accessory_name),
@@ -419,12 +439,15 @@ module KamalBackup
419
439
 
420
440
  include Helpers
421
441
 
422
- desc "backup", "Run one database and Active Storage backup immediately"
442
+ method_option :force, type: :boolean, default: false, desc: "Run a backup even if the configured schedule is not due"
443
+ desc "backup", "Run a due database and Active Storage backup"
423
444
  def backup
424
445
  if remote_command_mode?
425
- exec_remote(["kamal-backup", "backup"])
446
+ argv = ["kamal-backup", "backup"]
447
+ argv << "--force" if options[:force]
448
+ exec_remote(argv)
426
449
  else
427
- direct_app.backup
450
+ print_backup_result(direct_app.backup(force: options[:force]))
428
451
  end
429
452
  end
430
453
 
@@ -215,6 +215,10 @@ module KamalBackup
215
215
  File.join(state_dir, "last_check.json")
216
216
  end
217
217
 
218
+ def last_backup_path
219
+ File.join(state_dir, "last_backup.json")
220
+ end
221
+
218
222
  def last_restore_drill_path
219
223
  File.join(state_dir, "last_restore_drill.json")
220
224
  end
@@ -1,3 +1,3 @@
1
1
  module KamalBackup
2
- VERSION = "0.3.0.beta8"
2
+ VERSION = "0.3.0.beta10"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kamal-backup
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0.beta8
4
+ version: 0.3.0.beta10
5
5
  platform: ruby
6
6
  authors:
7
7
  - crmne