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.
@@ -1,313 +1,23 @@
1
- require "fileutils"
2
- require "json"
3
- require "shellwords"
4
- require "thor"
5
- require_relative "app"
6
- require_relative "config"
7
- require_relative "kamal_bridge"
8
- require_relative "redactor"
9
- require_relative "version"
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'thor'
5
+ require_relative 'app'
6
+ require_relative 'cli/helpers'
7
+ require_relative 'command_output'
8
+ require_relative 'config'
9
+ require_relative 'kamal_bridge'
10
+ require_relative 'redactor'
11
+ require_relative 'version'
10
12
 
11
13
  module KamalBackup
12
14
  class CLI < Thor
13
- module Helpers
14
- def command_env
15
- CLI.command_env || ENV
16
- end
17
-
18
- def redactor
19
- @redactor ||= Redactor.new(env: command_env)
20
- end
21
-
22
- def direct_app
23
- @direct_app ||= App.new(
24
- config: Config.new(env: command_env),
25
- redactor: redactor
26
- )
27
- end
28
-
29
- def local_restore_app
30
- @local_restore_app ||= App.new(
31
- config: local_command_config,
32
- redactor: redactor
33
- )
34
- end
35
-
36
- def local_preferences
37
- @local_preferences ||= Config.new(env: command_env)
38
- end
39
-
40
- def local_command_config
41
- @local_command_config ||= begin
42
- if deployment_mode?
43
- Config.new(
44
- env: command_env,
45
- defaults: production_source_defaults,
46
- config_paths: [Config::LOCAL_CONFIG_PATH]
47
- )
48
- else
49
- Config.new(env: command_env)
50
- end
51
- end
52
- end
53
-
54
- def production_source_defaults
55
- shared_config_source_defaults.merge(bridge.local_restore_defaults(accessory_name: accessory_name))
56
- end
57
-
58
- def shared_config_source_defaults
59
- config = Config.new(env: {}, config_paths: [Config::SHARED_CONFIG_PATH], load_project_defaults: false)
60
-
61
- {}.tap do |defaults|
62
- defaults["APP_NAME"] = config.app_name if config.app_name
63
- defaults["DATABASE_ADAPTER"] = config.database_adapter if config.database_adapter
64
- defaults["RESTIC_REPOSITORY"] = config.restic_repository if config.restic_repository
65
- defaults["LOCAL_RESTORE_SOURCE_PATHS"] = config.backup_paths.join("\n") if config.backup_paths.any?
66
- end
67
- end
68
-
69
- def bridge
70
- @bridge ||= KamalBridge.new(
71
- redactor: redactor,
72
- config_file: options[:config_file],
73
- destination: options[:destination],
74
- env: command_env,
75
- stdout: $stdout,
76
- stderr: $stderr
77
- )
78
- end
79
-
80
- def deployment_mode?
81
- !options[:destination].to_s.strip.empty? || !options[:config_file].to_s.strip.empty?
82
- end
83
-
84
- def default_deploy_config?
85
- File.file?(File.expand_path(KamalBridge::DEFAULT_CONFIG_FILE))
86
- end
87
-
88
- def remote_command_mode?
89
- deployment_mode? || default_deploy_config?
90
- end
91
-
92
- def accessory_name
93
- @accessory_name ||= bridge.accessory_name(preferred: local_preferences.accessory_name)
94
- end
95
-
96
- def remote_version
97
- @remote_version ||= bridge.remote_version(accessory_name: accessory_name)
98
- end
99
-
100
- def exec_remote(argv, require_version_match: true)
101
- ensure_remote_version_match! if require_version_match
102
-
103
- result = bridge.execute_on_accessory(
104
- accessory_name: accessory_name,
105
- command: Shellwords.join(argv),
106
- stream: true
107
- )
108
- print(result.stdout) unless result.streamed
109
- $stderr.print(result.stderr) if !result.streamed && !result.stderr.empty?
110
- result
111
- end
112
-
113
- def ensure_remote_version_match!
114
- return if remote_version == VERSION
115
-
116
- raise ConfigurationError, <<~MESSAGE.strip
117
- local gem version #{VERSION} does not match remote accessory version #{remote_version}.
118
- Reboot the backup accessory to pick up the latest image:
119
- #{accessory_reboot_command}
120
- MESSAGE
121
- end
122
-
123
- def accessory_reboot_command
124
- argv = ["bin/kamal", "accessory", "reboot", accessory_name]
125
- argv.concat(["-c", options[:config_file]]) if options[:config_file]
126
- argv.concat(["-d", options[:destination]]) if options[:destination]
127
- Shellwords.join(argv)
128
- end
129
-
130
- def print_remote_version_status
131
- status = remote_version == VERSION ? "in sync" : "out of sync"
132
- status_color = status == "in sync" ? :green : :red
133
- status_output = CommandOutput.new(io: $stdout, env: command_env)
134
-
135
- puts("local: #{VERSION}")
136
- puts("remote: #{remote_version}")
137
- puts("status: #{status_output.decorate(status, status_color, :bold)}")
138
- puts("fix: #{status_output.decorate(accessory_reboot_command, :yellow, :bold)}") if status == "out of sync"
139
- end
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
-
161
- def print_prune_result(results)
162
- output = Array(results).map(&:stdout).join
163
-
164
- if output.empty?
165
- puts("Prune completed")
166
- else
167
- print(output)
168
- puts unless output.end_with?("\n")
169
- end
170
- end
171
-
172
- def validate_deploy_config
173
- config = Config.new(
174
- env: bridge.accessory_environment(accessory_name: accessory_name),
175
- config_paths: [Config::SHARED_CONFIG_PATH],
176
- load_project_defaults: false
177
- )
178
- config.validate_backup(check_files: false)
179
- end
180
-
181
- def confirm!(message)
182
- return if options[:yes]
183
-
184
- unless $stdin.tty?
185
- raise ConfigurationError, "confirmation required; rerun with --yes"
186
- end
187
-
188
- unless yes?("#{message} [y/N]")
189
- raise ConfigurationError, "aborted"
190
- end
191
- end
192
-
193
- def confirm_production_restore!(snapshot)
194
- return if options[:"confirm-production-restore"]
195
-
196
- if options[:yes]
197
- raise ConfigurationError, "--yes does not bypass restore production; use --confirm-production-restore only for deliberate automation"
198
- end
199
-
200
- unless $stdin.tty?
201
- raise ConfigurationError, "production restore confirmation required; rerun interactively or pass --confirm-production-restore only for deliberate automation"
202
- end
203
-
204
- app_name = production_restore_confirmation_config.required_app_name
205
- say "This will overwrite the production database and file paths for #{app_name} from backup #{snapshot}.", :red
206
- require_typed_confirmation("Type the app name to continue", app_name)
207
- require_typed_confirmation("Type RESTORE PRODUCTION to continue", "RESTORE PRODUCTION")
208
- confirm!("Restore #{snapshot} into production now? This will overwrite production data.")
209
- end
210
-
211
- def require_typed_confirmation(prompt, expected)
212
- answer = ask("#{prompt}:").to_s.strip
213
- return if answer == expected
214
-
215
- raise ConfigurationError, "aborted"
216
- end
217
-
218
- def production_restore_confirmation_config
219
- if deployment_mode?
220
- Config.new(
221
- env: bridge.accessory_environment(accessory_name: accessory_name),
222
- config_paths: [Config::SHARED_CONFIG_PATH],
223
- load_project_defaults: false
224
- )
225
- else
226
- direct_app.config
227
- end
228
- end
229
-
230
- def prompt_required(label)
231
- unless $stdin.tty?
232
- raise ConfigurationError, "#{label.downcase} is required; pass it on the command line"
233
- end
234
-
235
- value = ask("#{label}:").to_s.strip
236
- if value.empty?
237
- raise ConfigurationError, "#{label.downcase} is required"
238
- else
239
- value
240
- end
241
- end
242
-
243
- def init_config_root
244
- config_file = options[:config_file] || KamalBridge::DEFAULT_CONFIG_FILE
245
- File.dirname(File.expand_path(config_file))
246
- end
247
-
248
- def shared_config_path
249
- File.join(init_config_root, "kamal-backup.yml")
250
- end
251
-
252
- def write_init_file(path, contents)
253
- if File.exist?(path)
254
- say "Exists: #{path}", :yellow
255
- else
256
- FileUtils.mkdir_p(File.dirname(path))
257
- File.write(path, contents)
258
- say "Created: #{path}", :green
259
- end
260
- end
261
-
262
- def shared_config_template
263
- <<~YAML
264
- app: your-app
265
- accessory: backup
266
- databases:
267
- - name: app
268
- adapter: postgres
269
- url: postgres://your-app@your-db:5432/your_app_production
270
- password:
271
- secret: DATABASE_PASSWORD
272
- paths:
273
- - /data/storage
274
- restic:
275
- repository: s3:https://s3.example.com/your-app-backups
276
- password:
277
- secret: RESTIC_PASSWORD
278
- init_if_missing: true
279
- backup:
280
- schedule: 1d
281
- YAML
282
- end
283
-
284
- def deploy_snippet
285
- <<~YAML
286
- accessories:
287
- backup:
288
- image: ghcr.io/crmne/kamal-backup:#{VERSION}
289
- host: your-server.example.com
290
- files:
291
- - config/kamal-backup.yml:/app/config/kamal-backup.yml:ro
292
- env:
293
- secret:
294
- - DATABASE_PASSWORD
295
- - RESTIC_PASSWORD
296
- - AWS_ACCESS_KEY_ID
297
- - AWS_SECRET_ACCESS_KEY
298
- volumes:
299
- - "your_app_storage:/data/storage:ro"
300
- - "your_app_backup_state:/var/lib/kamal-backup"
301
- YAML
302
- end
303
- end
304
-
305
15
  class CommandBase < Thor
306
16
  include Helpers
307
17
 
308
- class_option :yes, aliases: "-y", type: :boolean, default: false, desc: "Skip confirmation prompt"
309
- class_option :config_file, aliases: "-c", type: :string, desc: "Path to Kamal deploy config file"
310
- class_option :destination, aliases: "-d", type: :string, desc: "Kamal destination to use"
18
+ class_option :yes, aliases: '-y', type: :boolean, default: false, desc: 'Skip confirmation prompt'
19
+ class_option :config_file, aliases: '-c', type: :string, desc: 'Path to Kamal deploy config file'
20
+ class_option :destination, aliases: '-d', type: :string, desc: 'Kamal destination to use'
311
21
  remove_command :tree
312
22
  end
313
23
 
@@ -316,19 +26,20 @@ module KamalBackup
316
26
  CLI.basename
317
27
  end
318
28
 
319
- desc "local [SNAPSHOT]", "Restore the backup into the local database and Active Storage path"
320
- def local(snapshot = "latest")
29
+ desc 'local [SNAPSHOT]', 'Restore the backup into the local database and Active Storage path'
30
+ def local(snapshot = 'latest')
321
31
  confirm!("Restore #{snapshot} into the local database and Active Storage path? This will overwrite local data.")
322
32
  puts(JSON.pretty_generate(local_restore_app.restore_to_local_machine(snapshot)))
323
33
  end
324
34
 
325
- method_option :"confirm-production-restore", type: :boolean, default: false, desc: "Confirm production restore without interactive prompts"
326
- desc "production [SNAPSHOT]", "Restore the backup into the production database and Active Storage path"
327
- def production(snapshot = "latest")
35
+ method_option :"confirm-production-restore", type: :boolean, default: false,
36
+ desc: 'Confirm production restore without interactive prompts'
37
+ desc 'production [SNAPSHOT]', 'Restore the backup into the production database and Active Storage path'
38
+ def production(snapshot = 'latest')
328
39
  confirm_production_restore!(snapshot)
329
40
 
330
41
  if deployment_mode?
331
- exec_remote(["kamal-backup", "restore", "production", snapshot, "--confirm-production-restore"])
42
+ exec_remote(['kamal-backup', 'restore', 'production', snapshot, '--confirm-production-restore'])
332
43
  else
333
44
  puts(JSON.pretty_generate(direct_app.restore_to_production(snapshot)))
334
45
  end
@@ -340,28 +51,29 @@ module KamalBackup
340
51
  CLI.basename
341
52
  end
342
53
 
343
- method_option :check, type: :string, desc: "Run a verification command after the restore"
344
- desc "local [SNAPSHOT]", "Run a restore drill on the local machine"
345
- def local(snapshot = "latest")
54
+ method_option :check, type: :string, desc: 'Run a verification command after the restore'
55
+ desc 'local [SNAPSHOT]', 'Run a restore drill on the local machine'
56
+ def local(snapshot = 'latest')
346
57
  confirm!("Run a local restore drill for #{snapshot}? This will overwrite local data.")
347
58
  result = local_restore_app.drill_on_local_machine(snapshot, check_command: options[:check])
348
59
  puts(JSON.pretty_generate(result))
349
- exit(1) if local_restore_app.drill_failed?(result)
60
+ exit(1) unless result[:status] == 'ok'
350
61
  end
351
62
 
352
- method_option :database, type: :string, desc: "Scratch database name for PostgreSQL or MySQL"
353
- method_option :"sqlite-path", type: :string, desc: "Scratch SQLite path for production-side drills"
354
- method_option :files, type: :string, default: "/restore/files", desc: "Scratch Active Storage target for the drill"
355
- method_option :check, type: :string, desc: "Run a verification command after the restore"
356
- desc "production [SNAPSHOT]", "Run a restore drill on production infrastructure using scratch targets"
357
- def production(snapshot = "latest")
63
+ method_option :database, type: :string, desc: 'Scratch database name for PostgreSQL or MySQL'
64
+ method_option :"sqlite-path", type: :string, desc: 'Scratch SQLite path for production-side drills'
65
+ method_option :files, type: :string, default: '/restore/files',
66
+ desc: 'Scratch Active Storage target for the drill'
67
+ method_option :check, type: :string, desc: 'Run a verification command after the restore'
68
+ desc 'production [SNAPSHOT]', 'Run a restore drill on production infrastructure using scratch targets'
69
+ def production(snapshot = 'latest')
358
70
  confirm!("Run a production-side restore drill for #{snapshot}? This will restore into scratch targets on production infrastructure.")
359
71
 
360
72
  if deployment_mode?
361
- argv = ["kamal-backup", "drill", "production", snapshot, "--files", options[:files], "--yes"]
362
- argv.concat(["--database", production_database_name]) if production_database_name
363
- argv.concat(["--sqlite-path", options[:"sqlite-path"]]) if options[:"sqlite-path"]
364
- argv.concat(["--check", options[:check]]) if options[:check]
73
+ argv = ['kamal-backup', 'drill', 'production', snapshot, '--files', options[:files], '--yes']
74
+ argv.concat(['--database', production_database_name]) if production_database_name
75
+ argv.concat(['--sqlite-path', options[:"sqlite-path"]]) if options[:"sqlite-path"]
76
+ argv.concat(['--check', options[:check]]) if options[:check]
365
77
  exec_remote(argv)
366
78
  else
367
79
  result = direct_app.drill_on_production(
@@ -372,16 +84,16 @@ module KamalBackup
372
84
  check_command: options[:check]
373
85
  )
374
86
  puts(JSON.pretty_generate(result))
375
- exit(1) if direct_app.drill_failed?(result)
87
+ exit(1) unless result[:status] == 'ok'
376
88
  end
377
89
  end
378
90
 
379
91
  no_commands do
380
92
  def production_database_name
381
- if local_command_config.database_adapter == "sqlite"
93
+ if local_command_config.database_adapter == 'sqlite'
382
94
  nil
383
95
  else
384
- options[:database] || prompt_required("Scratch database name")
96
+ options[:database] || prompt_required('Scratch database name')
385
97
  end
386
98
  end
387
99
  end
@@ -398,7 +110,7 @@ module KamalBackup
398
110
  token = tokens.first
399
111
 
400
112
  case token
401
- when "-d", "--destination", "-c", "--config-file"
113
+ when '-d', '--destination', '-c', '--config-file'
402
114
  leading << tokens.shift
403
115
  leading << tokens.shift if tokens.any?
404
116
  when /\A--destination=.+\z/, /\A--config-file=.+\z/
@@ -416,18 +128,18 @@ module KamalBackup
416
128
  end
417
129
  end
418
130
 
419
- package_name "kamal-backup"
131
+ package_name 'kamal-backup'
420
132
  map %w[-v --version] => :version
421
- class_option :config_file, aliases: "-c", type: :string, desc: "Path to Kamal deploy config file"
422
- class_option :destination, aliases: "-d", type: :string, desc: "Kamal destination to use"
133
+ class_option :config_file, aliases: '-c', type: :string, desc: 'Path to Kamal deploy config file'
134
+ class_option :destination, aliases: '-d', type: :string, desc: 'Kamal destination to use'
423
135
  remove_command :tree
424
- desc "restore SUBCOMMAND ...ARGS", "Restore a database and Active Storage backup locally or into production"
425
- subcommand "restore", RestoreCLI
426
- desc "drill SUBCOMMAND ...ARGS", "Run a restore drill on the local machine or on production infrastructure"
427
- subcommand "drill", DrillCLI
136
+ desc 'restore SUBCOMMAND ...ARGS', 'Restore a database and Active Storage backup locally or into production'
137
+ subcommand 'restore', RestoreCLI
138
+ desc 'drill SUBCOMMAND ...ARGS', 'Run a restore drill on the local machine or on production infrastructure'
139
+ subcommand 'drill', DrillCLI
428
140
 
429
141
  def self.basename
430
- "kamal-backup"
142
+ 'kamal-backup'
431
143
  end
432
144
 
433
145
  def self.start(argv = ARGV, env: ENV)
@@ -436,17 +148,11 @@ module KamalBackup
436
148
  Command.with_output(output) do
437
149
  super(normalize_global_options(argv))
438
150
  end
439
- rescue Error => e
440
- output ||= CommandOutput.new(io: $stderr, env: env)
441
- output.error("(#{e.class}): #{e.message}", redactor: Redactor.new(env: env))
442
- exit(1)
443
151
  rescue StandardError => e
444
- output ||= CommandOutput.new(io: $stderr, env: env)
445
152
  output.error("(#{e.class}): #{e.message}", redactor: Redactor.new(env: env))
446
153
  exit(1)
447
154
  rescue Interrupt
448
- output ||= CommandOutput.new(io: $stderr, env: env)
449
- output.error("(Interrupt): interrupted", redactor: Redactor.new(env: env))
155
+ output.error('(Interrupt): interrupted', redactor: Redactor.new(env: env))
450
156
  exit(130)
451
157
  ensure
452
158
  self.command_env = nil
@@ -454,55 +160,56 @@ module KamalBackup
454
160
 
455
161
  include Helpers
456
162
 
457
- method_option :force, type: :boolean, default: false, desc: "Run a backup even if the configured schedule is not due"
458
- desc "backup", "Run a due database and Active Storage backup"
163
+ method_option :force, type: :boolean, default: false,
164
+ desc: 'Run a backup even if the configured schedule is not due'
165
+ desc 'backup', 'Run a due database and Active Storage backup'
459
166
  def backup
460
167
  if remote_command_mode?
461
- argv = ["kamal-backup", "backup"]
462
- argv << "--force" if options[:force]
168
+ argv = %w[kamal-backup backup]
169
+ argv << '--force' if options[:force]
463
170
  exec_remote(argv)
464
171
  else
465
172
  print_backup_result(direct_app.backup(force: options[:force]))
466
173
  end
467
174
  end
468
175
 
469
- desc "list", "List matching restic snapshots"
176
+ desc 'list', 'List matching restic snapshots'
470
177
  def list
471
178
  if remote_command_mode?
472
- exec_remote(["kamal-backup", "list"])
179
+ exec_remote(%w[kamal-backup list])
473
180
  else
474
181
  puts(direct_app.snapshots)
475
182
  end
476
183
  end
477
184
 
478
- desc "check", "Run restic check and record the latest result"
185
+ desc 'check', 'Run restic check and record the latest result'
479
186
  def check
480
187
  if remote_command_mode?
481
- exec_remote(["kamal-backup", "check"])
188
+ exec_remote(%w[kamal-backup check])
482
189
  else
483
190
  puts(direct_app.check)
484
191
  end
485
192
  end
486
193
 
487
- desc "prune", "Apply the configured restic retention policy and prune unneeded data"
194
+ desc 'prune', 'Apply the configured restic retention policy and prune unneeded data'
488
195
  def prune
489
196
  if remote_command_mode?
490
- exec_remote(["kamal-backup", "prune"])
197
+ exec_remote(%w[kamal-backup prune])
491
198
  else
492
199
  print_prune_result(direct_app.prune)
493
200
  end
494
201
  end
495
202
 
496
- desc "evidence", "Print redacted backup, check, and restore-drill evidence as JSON"
203
+ desc 'evidence', 'Print redacted backup, check, and restore-drill evidence as JSON'
497
204
  def evidence
498
205
  if remote_command_mode?
499
- exec_remote(["kamal-backup", "evidence"])
206
+ exec_remote(%w[kamal-backup evidence])
500
207
  else
501
208
  puts(direct_app.evidence)
502
209
  end
503
210
  end
504
211
 
505
- desc "validate", "Validate backup configuration without running a backup"
212
+ desc 'validate', 'Validate backup configuration without running a backup'
506
213
  def validate
507
214
  if remote_command_mode?
508
215
  validate_deploy_config
@@ -510,34 +217,34 @@ module KamalBackup
510
217
  direct_app.validate
511
218
  end
512
219
 
513
- puts("ok")
220
+ puts('ok')
514
221
  end
515
222
 
516
- desc "init", "Create config and print the scheduled backup accessory snippet"
223
+ desc 'init', 'Create config and print the scheduled backup accessory snippet'
517
224
  def init
518
225
  write_init_file(shared_config_path, shared_config_template)
519
226
 
520
227
  puts
521
- puts "Add this accessory block to your Kamal deploy config:"
228
+ puts 'Add this accessory block to your Kamal deploy config:'
522
229
  puts
523
230
  puts deploy_snippet
524
231
  puts
525
- puts "The accessory runs scheduled database and file backups with backup.schedule."
526
- puts "For most Rails apps, restore local and drill local can infer the development database, Active Storage path, and tmp state directory."
527
- puts "Local restore and drill also require the restic binary on your machine."
528
- puts "Create config/kamal-backup.local.yml only if you need to override those local defaults."
232
+ puts 'The accessory runs scheduled database and file backups with backup.schedule.'
233
+ puts 'For most Rails apps, restore local and drill local can infer the development database, Active Storage path, and tmp state directory.'
234
+ puts 'Local restore and drill also require the restic binary on your machine.'
235
+ puts 'Create config/kamal-backup.local.yml only if you need to override those local defaults.'
529
236
  end
530
237
 
531
- desc "schedule", "Run the foreground scheduler loop"
238
+ desc 'schedule', 'Run the foreground scheduler loop'
532
239
  def schedule
533
240
  if deployment_mode?
534
- exec_remote(["kamal-backup", "schedule"])
241
+ exec_remote(%w[kamal-backup schedule])
535
242
  else
536
243
  direct_app.schedule
537
244
  end
538
245
  end
539
246
 
540
- desc "version", "Print the running kamal-backup version"
247
+ desc 'version', 'Print the running kamal-backup version'
541
248
  def version
542
249
  if remote_command_mode?
543
250
  print_remote_version_status
@@ -545,6 +252,5 @@ module KamalBackup
545
252
  puts(VERSION)
546
253
  end
547
254
  end
548
-
549
255
  end
550
256
  end