kamal-backup 0.4.0 → 0.4.2

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: 4b93c203599481320559eb006ccf07a07c010c5bc4d5dfcae3e11b7a9a832fa3
4
- data.tar.gz: 705fc119fcacdd54ee431e9cfb5b88220a0faae02540cba77e38c45b94044905
3
+ metadata.gz: 0442e12748359ee182d6fc91e5573375440c31a5a65ccf1b46079547b8e4bc21
4
+ data.tar.gz: 9af03ddf3ad23b99b77c717711dc66cde34bc17500e66e776aff50ca2678e653
5
5
  SHA512:
6
- metadata.gz: fd7b30595fe843980d39979b8b7e241fbd54c7fd814c55bccb4bfcdf6b175b5767e84910929050f4e3aa40f150aec5b01e15015b196e522e3c48566f9527020f
7
- data.tar.gz: e81f5d21997f6f072b9556a73b7e751accbe99098adaebaec3435ca15600e8d13f204b9d343cc3ae7f6905a86efd3abea3c5dd0d64ea602bd2f2617d8bca8fef
6
+ metadata.gz: ccd324da250d8a36f623a6c65c9cf000c80e4fbcc0308944c1074c67ce9422e1e637c55e672160399078944c83c281ccec25ff17a63d1181ed28045244a2d674
7
+ data.tar.gz: 80f64be478843d1695cdfa34c899ba614ee7ed45a10853f521c08ee568f003f3b0045809ef1ff0c1217e5ec834d2a607888963998af64f9643886048f0e65bf1
data/README.md CHANGED
@@ -68,11 +68,13 @@ accessories:
68
68
  - AWS_ACCESS_KEY_ID
69
69
  - AWS_SECRET_ACCESS_KEY
70
70
  volumes:
71
- - "chatwithwork_storage:/data/storage:ro"
71
+ - "chatwithwork_storage:/data/storage"
72
72
  - "chatwithwork_backup_state:/var/lib/kamal-backup"
73
73
  ```
74
74
 
75
- For SQLite databases stored on the mounted storage volume, omit `:ro` from that volume.
75
+ The storage volume is writable so `restore production` can replace its contents safely without replacing the mount
76
+ point. Writable access is also required for SQLite databases stored on that volume. For a backup-only accessory,
77
+ you can append `:ro`; remove it and reboot the accessory before a production file restore.
76
78
  When the configured SQLite database file lives under a configured file backup path, `kamal-backup` excludes the raw database, WAL, and shared-memory files from the restic file snapshot automatically.
77
79
 
78
80
  Put the backup settings in `config/kamal-backup.yml`:
@@ -97,6 +99,8 @@ backup:
97
99
  schedule: 1d
98
100
  ```
99
101
 
102
+ Only paths explicitly listed under `paths` are included in file snapshots. Omit `paths` for a database-only backup; `kamal-backup` never infers `storage` from Rails.
103
+
100
104
  Boot it. The container runs `kamal-backup schedule` by default:
101
105
 
102
106
  ```sh
@@ -58,8 +58,8 @@ module KamalBackup
58
58
 
59
59
  build_restore_result('local', snapshot) do |result|
60
60
  databases.each { |adapter| validate_local_machine_database_target(adapter) }
61
- result[:databases] = perform_database_restores_to_current(snapshot)
62
61
  result[:files] = perform_replacement_file_restore(snapshot, production_source: false)
62
+ result[:databases] = perform_database_restores_to_current(snapshot)
63
63
  end
64
64
  end
65
65
 
@@ -67,8 +67,8 @@ module KamalBackup
67
67
  validate_production_restore
68
68
 
69
69
  build_restore_result('production', snapshot) do |result|
70
- result[:databases] = perform_database_restores_to_current(snapshot)
71
70
  result[:files] = perform_replacement_file_restore(snapshot, production_source: true)
71
+ result[:databases] = perform_database_restores_to_current(snapshot)
72
72
  end
73
73
  end
74
74
 
@@ -77,8 +77,8 @@ module KamalBackup
77
77
 
78
78
  run_drill('local', snapshot, check_command: check_command) do |result|
79
79
  databases.each { |adapter| validate_local_machine_database_target(adapter) }
80
- result[:databases] = perform_database_restores_to_current(snapshot)
81
80
  result[:files] = perform_replacement_file_restore(snapshot, production_source: false)
81
+ result[:databases] = perform_database_restores_to_current(snapshot)
82
82
  end
83
83
  end
84
84
 
@@ -87,6 +87,7 @@ module KamalBackup
87
87
  validate_production_drill(file_target, database_name, sqlite_path)
88
88
 
89
89
  run_drill('production', snapshot, check_command: check_command) do |result|
90
+ result[:files] = perform_file_restore(snapshot, target: file_target)
90
91
  result[:databases] = databases.map do |adapter|
91
92
  perform_database_restore_to_scratch(
92
93
  snapshot,
@@ -95,7 +96,6 @@ module KamalBackup
95
96
  sqlite_path: sqlite_path
96
97
  )
97
98
  end
98
- result[:files] = perform_file_restore(snapshot, target: file_target)
99
99
  end
100
100
  end
101
101
 
@@ -374,6 +374,8 @@ module KamalBackup
374
374
  end
375
375
 
376
376
  def perform_replacement_file_restore(snapshot, production_source:)
377
+ return nil if config.backup_paths.empty?
378
+
377
379
  source_paths = production_source ? config.backup_paths : config.local_restore_source_paths
378
380
  target_paths = config.backup_paths
379
381
  resolved_snapshot = resolve_snapshot(snapshot, tags: ['type:files'])
@@ -390,20 +392,52 @@ module KamalBackup
390
392
  end
391
393
 
392
394
  def replace_target_paths(stage_dir, source_paths:, target_paths:)
393
- source_paths.zip(target_paths).each do |source_path, target_path|
395
+ path_pairs = source_paths.zip(target_paths)
396
+ validate_replacement_paths(stage_dir, path_pairs)
397
+
398
+ path_pairs.each do |source_path, target_path|
394
399
  replace_target_path(stage_dir, source_path, target_path)
395
400
  end
396
401
  end
397
402
 
403
+ def validate_replacement_paths(stage_dir, path_pairs)
404
+ path_pairs.each do |source_path, target_path|
405
+ source = staged_backup_path(stage_dir, source_path)
406
+ raise ConfigurationError, "restored file snapshot is missing #{source_path}" unless File.exist?(source)
407
+
408
+ ensure_writable_restore_target(target_path)
409
+ end
410
+ end
411
+
398
412
  def replace_target_path(stage_dir, source_path, target_path)
399
413
  source = staged_backup_path(stage_dir, source_path)
400
414
  target = File.expand_path(target_path)
401
415
 
402
416
  raise ConfigurationError, "restored file snapshot is missing #{source_path}" unless File.exist?(source)
403
417
 
404
- FileUtils.rm_rf(target)
405
- FileUtils.mkdir_p(File.dirname(target))
406
- FileUtils.mv(source, target)
418
+ if File.directory?(source) && File.directory?(target) && !File.symlink?(target)
419
+ replace_directory_contents(source, target)
420
+ else
421
+ FileUtils.remove_entry(target) if File.exist?(target) || File.symlink?(target)
422
+ FileUtils.mkdir_p(File.dirname(target))
423
+ FileUtils.mv(source, target)
424
+ end
425
+ rescue Errno::EACCES, Errno::EPERM, Errno::EROFS => e
426
+ raise ConfigurationError, "cannot restore files to #{target}: target must be writable (#{e.message})"
427
+ end
428
+
429
+ def ensure_writable_restore_target(target_path)
430
+ target = File.expand_path(target_path)
431
+ return unless File.directory?(target) && !File.symlink?(target)
432
+
433
+ Dir.mktmpdir('.kamal-backup-write-test-', target) {}
434
+ rescue Errno::EACCES, Errno::EPERM, Errno::EROFS => e
435
+ raise ConfigurationError, "cannot restore files to #{target}: target must be writable (#{e.message})"
436
+ end
437
+
438
+ def replace_directory_contents(source, target)
439
+ Dir.children(target).each { |entry| FileUtils.remove_entry(File.join(target, entry)) }
440
+ Dir.children(source).each { |entry| FileUtils.mv(File.join(source, entry), target) }
407
441
  end
408
442
 
409
443
  def staged_backup_path(stage_dir, path)
@@ -411,6 +445,8 @@ module KamalBackup
411
445
  end
412
446
 
413
447
  def perform_file_restore(snapshot, target:)
448
+ return nil if config.backup_paths.empty?
449
+
414
450
  resolved_snapshot = resolve_snapshot(snapshot, tags: ['type:files'])
415
451
  validated_target = config.validate_file_restore_target(target)
416
452
  restic.restore_snapshot(resolved_snapshot, validated_target)
@@ -436,7 +472,7 @@ module KamalBackup
436
472
  def validate_production_drill(file_target, database_name, sqlite_path)
437
473
  config.validate_restic
438
474
  config.validate_database_backup
439
- config.validate_file_restore_target(file_target)
475
+ config.validate_file_restore_target(file_target) if config.backup_paths.any?
440
476
 
441
477
  databases.each do |adapter|
442
478
  case adapter.adapter_name
@@ -289,7 +289,7 @@ module KamalBackup
289
289
  - AWS_ACCESS_KEY_ID
290
290
  - AWS_SECRET_ACCESS_KEY
291
291
  volumes:
292
- - "your_app_storage:/data/storage:ro"
292
+ - "your_app_storage:/data/storage"
293
293
  - "your_app_backup_state:/var/lib/kamal-backup"
294
294
  YAML
295
295
  end
@@ -26,15 +26,15 @@ module KamalBackup
26
26
  CLI.basename
27
27
  end
28
28
 
29
- desc 'local [SNAPSHOT]', 'Restore the backup into the local database and Active Storage path'
29
+ desc 'local [SNAPSHOT]', 'Restore the backup into the local database and configured file paths'
30
30
  def local(snapshot = 'latest')
31
- confirm!("Restore #{snapshot} into the local database and Active Storage path? This will overwrite local data.")
31
+ confirm!("Restore #{snapshot} into the local database and configured file paths? This will overwrite local data.")
32
32
  puts(JSON.pretty_generate(local_restore_app.restore_to_local_machine(snapshot)))
33
33
  end
34
34
 
35
35
  method_option :"confirm-production-restore", type: :boolean, default: false,
36
36
  desc: 'Confirm production restore without interactive prompts'
37
- desc 'production [SNAPSHOT]', 'Restore the backup into the production database and Active Storage path'
37
+ desc 'production [SNAPSHOT]', 'Restore the backup into the production database and configured file paths'
38
38
  def production(snapshot = 'latest')
39
39
  confirm_production_restore!(snapshot)
40
40
 
@@ -133,7 +133,7 @@ module KamalBackup
133
133
  class_option :config_file, aliases: '-c', type: :string, desc: 'Path to Kamal deploy config file'
134
134
  class_option :destination, aliases: '-d', type: :string, desc: 'Kamal destination to use'
135
135
  remove_command :tree
136
- desc 'restore SUBCOMMAND ...ARGS', 'Restore a database and Active Storage backup locally or into production'
136
+ desc 'restore SUBCOMMAND ...ARGS', 'Restore database and configured file backups locally or into production'
137
137
  subcommand 'restore', RestoreCLI
138
138
  desc 'drill SUBCOMMAND ...ARGS', 'Run a restore drill on the local machine or on production infrastructure'
139
139
  subcommand 'drill', DrillCLI
@@ -162,7 +162,7 @@ module KamalBackup
162
162
 
163
163
  method_option :force, type: :boolean, default: false,
164
164
  desc: 'Run a backup even if the configured schedule is not due'
165
- desc 'backup', 'Run a due database and Active Storage backup'
165
+ desc 'backup', 'Run a due database and configured file backup'
166
166
  def backup
167
167
  if remote_command_mode?
168
168
  argv = %w[kamal-backup backup]
@@ -238,10 +238,11 @@ module KamalBackup
238
238
  puts
239
239
  puts deploy_snippet
240
240
  puts
241
- puts 'The accessory runs scheduled database and file backups with backup.schedule.'
242
- puts 'For most Rails apps, restore local and drill local can infer the development database, Active Storage path, and tmp state directory.'
241
+ puts 'The accessory runs scheduled database and configured file backups with backup.schedule.'
242
+ puts 'File backups run only for paths explicitly configured in config/kamal-backup.yml.'
243
+ puts 'Rails local restores infer the development database and tmp state directory, but never file paths.'
243
244
  puts 'Local restore and drill also require the restic binary on your machine.'
244
- puts 'Create config/kamal-backup.local.yml only if you need to override those local defaults.'
245
+ puts 'When production paths are configured, set their local targets in config/kamal-backup.local.yml.'
245
246
  end
246
247
 
247
248
  desc 'schedule', 'Run the foreground scheduler loop'
@@ -214,7 +214,10 @@ module KamalBackup
214
214
  source_paths = local_restore_source_paths
215
215
  target_paths = backup_paths
216
216
 
217
- raise ConfigurationError, 'local restore source paths must contain the same number of paths as file paths' unless source_paths.size == target_paths.size
217
+ unless source_paths.size == target_paths.size
218
+ raise ConfigurationError,
219
+ 'local file paths must be explicitly configured and match the number of production restore source paths'
220
+ end
218
221
 
219
222
  source_paths.zip(target_paths)
220
223
  end
@@ -22,9 +22,6 @@ module KamalBackup
22
22
  {}.tap do |defaults|
23
23
  defaults.merge!(deploy_defaults)
24
24
  defaults.merge!(database_defaults)
25
-
26
- defaults['BACKUP_PATHS'] = local_storage_path if local_storage_path
27
-
28
25
  defaults['KAMAL_BACKUP_STATE_DIR'] = File.join(@cwd, 'tmp', 'kamal-backup')
29
26
  end
30
27
  end
@@ -90,10 +87,6 @@ module KamalBackup
90
87
  end
91
88
  end
92
89
 
93
- def local_storage_path
94
- File.join(@cwd, 'storage')
95
- end
96
-
97
90
  def local_database_config
98
91
  environment = fetch(parsed_yaml(database_config_path), DEVELOPMENT_ENV)
99
92
  return nil unless environment.is_a?(Hash)
@@ -28,13 +28,12 @@ module KamalBackup
28
28
  end
29
29
 
30
30
  def backup_stream(command, filename:, tags:)
31
- restic_command = CommandSpec.new(
32
- argv: %w[restic
33
- backup] + host_args + ['--stdin', '--stdin-filename', filename] + tag_args(common_tags + tags),
34
- env: restic_env
35
- )
36
31
  log("backing up stream as #{filename}")
37
- pipe_commands(command, restic_command, producer_label: 'dump', consumer_label: 'restic backup')
32
+ run(
33
+ ['backup'] + host_args + ['--stdin-filename', filename] + tag_args(common_tags + tags) +
34
+ ['--stdin-from-command', '--'] + command.argv,
35
+ env: restic_env.merge(command.env)
36
+ )
38
37
  end
39
38
 
40
39
  def backup_file(path, filename:, tags:)
@@ -211,9 +210,9 @@ module KamalBackup
211
210
 
212
211
  private
213
212
 
214
- def run(args, log_output: true)
213
+ def run(args, log_output: true, env: restic_env)
215
214
  Command.capture(
216
- CommandSpec.new(argv: ['restic'] + args, env: restic_env),
215
+ CommandSpec.new(argv: ['restic'] + args, env: env),
217
216
  redactor: redactor,
218
217
  log_output: log_output
219
218
  )
@@ -24,7 +24,7 @@ module KamalBackup
24
24
  @backup_block.call
25
25
  log("backup completed at #{Time.now.utc.iso8601}")
26
26
  rescue StandardError => e
27
- warn("[kamal-backup] backup failed at #{Time.now.utc.iso8601}: #{e.class}: #{e.message}")
27
+ warn("ERROR [kamal-backup] backup failed at #{Time.now.utc.iso8601}: #{e.class}: #{e.message}")
28
28
  end
29
29
 
30
30
  sleep_interruptibly(@config.backup_schedule_seconds)
@@ -49,7 +49,7 @@ module KamalBackup
49
49
  end
50
50
 
51
51
  def log(message)
52
- $stdout.puts("[kamal-backup] #{message}")
52
+ $stdout.puts("INFO [kamal-backup] #{message}")
53
53
  end
54
54
  end
55
55
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module KamalBackup
4
- VERSION = '0.4.0'
4
+ VERSION = '0.4.2'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kamal-backup
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - crmne
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-21 00:00:00.000000000 Z
11
+ date: 2026-08-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor