kamal-backup 0.2.10 → 0.3.0.beta2

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: 6e3653f6bad9c7d736b7617e059bfd19263854ab96596bfb54191129a4b438fc
4
- data.tar.gz: f3ecbde3269e9f3aee1c9bb227bce2b054abaddfa3c732c6aff4eecc24b1c8b6
3
+ metadata.gz: 55945234709fcf743588fe6fadf7873ff43dcc832a2bc8303e42ba67e15c13db
4
+ data.tar.gz: a3e05e3428bdcceafa37d2dc2f0cdea53d50e204f7c9345e38308be0085ba9ea
5
5
  SHA512:
6
- metadata.gz: 4974fc0bea1a6b2212d0bf7a968fd2dbc993bfdfdeeb13ae37989ab8d43d7932ce3e20f691b62ce0c744e307b560da19ca4b169f935a8353ffa1317c270af919
7
- data.tar.gz: 2904cb1a0481b49f61914d082ae2113358000abebe98ace0789c4eaf1993ed1102758030a58bf80c6afc61c7c79bc1da613749cbb5579a2ff584edaa2d2fa87e
6
+ metadata.gz: f23ce66cf13a8b420d6171e16489f2c4e2bc44c522b222637eac0ac809e1092e8634396589a1ea4b58c9f01e3ef446f57e4b8f38a157be3423e482cbe8bd2e9a
7
+ data.tar.gz: 103ff0d7ffb950b7f6927a8c22591e7f91c8cc06d5456bb04a6b119eda21f7198aa949375d19e3689040db34c8c2a4dc8ed4b2ab6e59911672e4b9033b700fe5
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h1>kamal-backup</h1>
6
6
 
7
- <strong>The easiest way to run scheduled backups for a Rails app deployed with Kamal.</strong>
7
+ <strong>Add scheduled Rails backups to Kamal with one accessory.</strong>
8
8
 
9
9
  [![Gem Version](https://img.shields.io/gem/v/kamal-backup.svg)](https://rubygems.org/gems/kamal-backup)
10
10
  [![CI](https://github.com/crmne/kamal-backup/actions/workflows/ci.yml/badge.svg)](https://github.com/crmne/kamal-backup/actions/workflows/ci.yml)
@@ -20,7 +20,7 @@ Backups for Rails apps deployed with Kamal should not become a separate ops proj
20
20
 
21
21
  `kamal-backup` is one Kamal accessory that runs encrypted backups for your Rails database and file-backed Active Storage files on a schedule. It also gives you restore drills and redacted evidence for security reviews like CASA.
22
22
 
23
- If you already deploy with Kamal, backups should feel like adding one more accessory.
23
+ Run `kamal-backup init`, fill in one config file, add the accessory, then boot it. If you already deploy with Kamal, backups should feel like adding one more accessory.
24
24
 
25
25
  ## Why Rails teams use it
26
26
 
@@ -28,11 +28,11 @@ Most self-hosted Rails apps need the same things:
28
28
 
29
29
  - scheduled backups for PostgreSQL, MySQL/MariaDB, or SQLite
30
30
  - file-backed Active Storage backups from mounted volumes
31
- - a fast way to restore production data locally
31
+ - local restores for inspecting production data safely
32
32
  - restore drills that do not touch the live production database
33
33
  - evidence that says more than "the backup ran"
34
34
 
35
- `kamal-backup` packages that workflow into a small Ruby gem, a production accessory image, and a restic repository.
35
+ `kamal-backup` wraps that workflow in a small Ruby gem and a production accessory image backed by a restic repository.
36
36
 
37
37
  ## Quick start
38
38
 
@@ -62,7 +62,7 @@ accessories:
62
62
  - config/kamal-backup.yml:/app/config/kamal-backup.yml:ro
63
63
  env:
64
64
  secret:
65
- - PGPASSWORD
65
+ - DATABASE_PASSWORD
66
66
  - RESTIC_PASSWORD
67
67
  - AWS_ACCESS_KEY_ID
68
68
  - AWS_SECRET_ACCESS_KEY
@@ -76,15 +76,23 @@ For SQLite databases stored on the mounted storage volume, omit `:ro` from that
76
76
  Put the backup settings in `config/kamal-backup.yml`:
77
77
 
78
78
  ```yaml
79
+ app: chatwithwork
79
80
  accessory: backup
80
- app_name: chatwithwork
81
- database_adapter: postgres
82
- database_url: postgres://chatwithwork@chatwithwork-db:5432/chatwithwork_production
83
- backup_paths:
81
+ databases:
82
+ - name: app
83
+ adapter: postgres
84
+ url: postgres://chatwithwork@chatwithwork-db:5432/chatwithwork_production
85
+ password:
86
+ secret: DATABASE_PASSWORD
87
+ paths:
84
88
  - /data/storage
85
- restic_repository: s3:https://s3.example.com/chatwithwork-backups
86
- restic_init_if_missing: true
87
- backup_schedule_seconds: 86400
89
+ restic:
90
+ repository: s3:https://s3.example.com/chatwithwork-backups
91
+ password:
92
+ secret: RESTIC_PASSWORD
93
+ init_if_missing: true
94
+ backup:
95
+ schedule: 1d
88
96
  ```
89
97
 
90
98
  Boot it. The container runs `kamal-backup schedule` by default:
@@ -95,22 +103,23 @@ bin/kamal accessory boot backup
95
103
  bin/kamal accessory logs backup
96
104
  ```
97
105
 
98
- Run the first backup and print evidence. From an app checkout with `config/deploy.yml`, these commands shell out through Kamal to the backup accessory:
106
+ Run the first backup, check the repository, and print evidence. From an app checkout with `config/deploy.yml`, these commands shell out through Kamal to the backup accessory:
99
107
 
100
108
  ```sh
101
109
  bundle exec kamal-backup backup
102
110
  bundle exec kamal-backup list
111
+ bundle exec kamal-backup check
103
112
  bundle exec kamal-backup evidence
104
113
  ```
105
114
 
106
115
  ## What you get
107
116
 
108
- - **Scheduled backups:** the accessory runs continuously and backs up on `backup_schedule_seconds`.
117
+ - **Scheduled backups:** the accessory runs continuously and backs up on `backup.schedule`.
109
118
  - **Database and Active Storage coverage:** database dumps plus file-backed Active Storage files from mounted volumes.
110
119
  - **Restic underneath:** encrypted, deduplicated snapshots in S3-compatible storage, a restic REST server, or a filesystem repository.
111
- - **Local restores:** pull production backups into your local Rails app when you need to inspect real data.
112
- - **Restore drills:** restore into scratch production-side targets and record the result.
113
- - **Security review evidence:** `kamal-backup evidence` prints redacted JSON with latest snapshots, checks, drills, retention, and tool versions.
120
+ - **Local restores:** inspect production data safely in your local Rails app.
121
+ - **Restore drills:** restore into scratch production-side targets, run verification commands, and record the result.
122
+ - **Security review evidence:** `kamal-backup evidence` prints redacted JSON with latest snapshots, `kamal-backup check` results, drills, retention, and tool versions.
114
123
 
115
124
  ## Docs
116
125
 
@@ -132,9 +141,9 @@ Run the release helper from a clean `master` checkout:
132
141
  bin/release 0.2.9
133
142
  ```
134
143
 
135
- It updates `lib/kamal_backup/version.rb`, runs the test suite and docs build, commits `Release 0.2.9`, creates `v0.2.9`, and pushes `master` plus the tag. CI publishes the RubyGem, Docker image tags, GitHub release, and docs from that release commit.
144
+ It updates `lib/kamal_backup/version.rb`, runs the test suite and docs build, commits `Release 0.2.9`, and pushes `master`. CI publishes the RubyGem and Docker image tags first, then creates `v0.2.9`, the GitHub release, and the docs deployment from the release commit.
136
145
 
137
- Use `bin/release 0.2.9 --no-push` to prepare the commit and tag locally without publishing.
146
+ Use `bin/release 0.2.9 --no-push` to prepare the commit locally without publishing.
138
147
 
139
148
  ## License
140
149
 
@@ -33,7 +33,7 @@ module KamalBackup
33
33
 
34
34
  timestamp = current_timestamp
35
35
  restic.ensure_repository
36
- database.backup(restic, timestamp)
36
+ databases.each { |database| database.backup(restic, timestamp) }
37
37
  restic.backup_paths(config.backup_paths, tags: ["type:files", "run:#{timestamp}"])
38
38
 
39
39
  if config.forget_after_backup?
@@ -57,14 +57,10 @@ module KamalBackup
57
57
  require_restic!
58
58
 
59
59
  build_restore_result("local", snapshot) do |result|
60
- adapter = database
61
- validate_local_machine_database_target(adapter)
62
- result[:database] = perform_database_restore_to_current(snapshot, adapter: adapter)
63
- result[:files] = perform_replacement_file_restore(
64
- snapshot,
65
- source_paths: config.local_restore_source_paths,
66
- target_paths: config.backup_paths
67
- )
60
+ databases.each { |adapter| validate_local_machine_database_target(adapter) }
61
+ database_results = perform_database_restores_to_current(snapshot)
62
+ file_results = perform_replacement_file_restores(snapshot, production_source: false)
63
+ assign_restore_results(result, database_results, file_results)
68
64
  end
69
65
  end
70
66
 
@@ -73,13 +69,9 @@ module KamalBackup
73
69
  require_restic!
74
70
 
75
71
  build_restore_result("production", snapshot) do |result|
76
- adapter = database
77
- result[:database] = perform_database_restore_to_current(snapshot, adapter: adapter)
78
- result[:files] = perform_replacement_file_restore(
79
- snapshot,
80
- source_paths: config.backup_paths,
81
- target_paths: config.backup_paths
82
- )
72
+ database_results = perform_database_restores_to_current(snapshot)
73
+ file_results = perform_replacement_file_restores(snapshot, production_source: true)
74
+ assign_restore_results(result, database_results, file_results)
83
75
  end
84
76
  end
85
77
 
@@ -88,14 +80,10 @@ module KamalBackup
88
80
  require_restic!
89
81
 
90
82
  run_drill("local", snapshot, check_command: check_command) do |result|
91
- adapter = database
92
- validate_local_machine_database_target(adapter)
93
- result[:database] = perform_database_restore_to_current(snapshot, adapter: adapter)
94
- result[:files] = perform_replacement_file_restore(
95
- snapshot,
96
- source_paths: config.local_restore_source_paths,
97
- target_paths: config.backup_paths
98
- )
83
+ databases.each { |adapter| validate_local_machine_database_target(adapter) }
84
+ database_results = perform_database_restores_to_current(snapshot)
85
+ file_results = perform_replacement_file_restores(snapshot, production_source: false)
86
+ assign_restore_results(result, database_results, file_results)
99
87
  end
100
88
  end
101
89
 
@@ -104,14 +92,16 @@ module KamalBackup
104
92
  require_restic!
105
93
 
106
94
  run_drill("production", snapshot, check_command: check_command) do |result|
107
- adapter = database
108
- result[:database] = perform_database_restore_to_scratch(
109
- snapshot,
110
- adapter: adapter,
111
- database_name: database_name,
112
- sqlite_path: sqlite_path
113
- )
114
- result[:files] = perform_file_restore(snapshot, target: file_target)
95
+ database_results = databases.map do |adapter|
96
+ perform_database_restore_to_scratch(
97
+ snapshot,
98
+ adapter: adapter,
99
+ database_name: database_name,
100
+ sqlite_path: sqlite_path
101
+ )
102
+ end
103
+ file_results = [perform_file_restore(snapshot, target: file_target)]
104
+ assign_restore_results(result, database_results, file_results)
115
105
  end
116
106
  end
117
107
 
@@ -160,7 +150,9 @@ module KamalBackup
160
150
  finished_at: nil,
161
151
  error: nil,
162
152
  database: nil,
163
- files: nil
153
+ databases: [],
154
+ files: nil,
155
+ paths: nil
164
156
  )
165
157
  yield(result)
166
158
  result[:finished_at] = Time.now.utc.iso8601
@@ -179,7 +171,9 @@ module KamalBackup
179
171
  finished_at: nil,
180
172
  error: nil,
181
173
  database: nil,
174
+ databases: [],
182
175
  files: nil,
176
+ paths: nil,
183
177
  check: nil
184
178
  )
185
179
 
@@ -206,8 +200,8 @@ module KamalBackup
206
200
  end
207
201
 
208
202
  def perform_database_restore_to_current(snapshot, adapter:)
209
- resolved_snapshot = resolve_snapshot(snapshot, tags: ["type:database", "adapter:#{adapter.adapter_name}"])
210
- filename = restic.database_file(resolved_snapshot, adapter.adapter_name)
203
+ resolved_snapshot = resolve_snapshot(snapshot, tags: database_snapshot_tags(adapter))
204
+ filename = restic.database_file(resolved_snapshot, adapter.adapter_name, database_name: database_config_name(adapter))
211
205
 
212
206
  if filename
213
207
  adapter.restore_to_current(restic, resolved_snapshot, filename)
@@ -219,8 +213,8 @@ module KamalBackup
219
213
 
220
214
  def perform_database_restore_to_scratch(snapshot, adapter:, database_name:, sqlite_path:)
221
215
  target = scratch_database_target(adapter, database_name, sqlite_path)
222
- resolved_snapshot = resolve_snapshot(snapshot, tags: ["type:database", "adapter:#{adapter.adapter_name}"])
223
- filename = restic.database_file(resolved_snapshot, adapter.adapter_name)
216
+ resolved_snapshot = resolve_snapshot(snapshot, tags: database_snapshot_tags(adapter))
217
+ filename = restic.database_file(resolved_snapshot, adapter.adapter_name, database_name: database_config_name(adapter))
224
218
 
225
219
  if filename
226
220
  adapter.restore_to_scratch(restic, resolved_snapshot, filename, target: target)
@@ -230,6 +224,40 @@ module KamalBackup
230
224
  end
231
225
  end
232
226
 
227
+ def perform_database_restores_to_current(snapshot)
228
+ databases.map { |adapter| perform_database_restore_to_current(snapshot, adapter: adapter) }
229
+ end
230
+
231
+ def perform_replacement_file_restores(snapshot, production_source:)
232
+ source_paths = production_source ? config.backup_paths : config.local_restore_source_paths
233
+ [
234
+ perform_replacement_file_restore(
235
+ snapshot,
236
+ source_paths: source_paths,
237
+ target_paths: config.backup_paths
238
+ )
239
+ ]
240
+ end
241
+
242
+ def assign_restore_results(result, database_results, file_results)
243
+ result[:databases] = database_results
244
+ result[:database] = database_results.first
245
+ result[:paths] = file_results.first
246
+ result[:files] = file_results.size == 1 ? file_results.first : file_results
247
+ end
248
+
249
+ def database_snapshot_tags(adapter)
250
+ ["type:database", "database:#{database_config_name(adapter)}", "adapter:#{adapter.adapter_name}"]
251
+ end
252
+
253
+ def database_config_name(adapter)
254
+ if adapter.respond_to?(:config) && adapter.config.respond_to?(:database_name)
255
+ adapter.config.database_name
256
+ else
257
+ config.database_name
258
+ end
259
+ end
260
+
233
261
  def perform_file_restore(snapshot, target:)
234
262
  resolved_snapshot = resolve_snapshot(snapshot, tags: ["type:files"])
235
263
  validated_target = config.validate_file_restore_target(target)
@@ -290,9 +318,11 @@ module KamalBackup
290
318
  def scratch_database_target(adapter, database_name, sqlite_path)
291
319
  case adapter.adapter_name
292
320
  when "sqlite"
293
- sqlite_path || raise(ConfigurationError, "scratch SQLite path is required")
321
+ target = sqlite_path || raise(ConfigurationError, "scratch SQLite path is required")
322
+ databases.size > 1 ? File.join(target, "#{database_config_name(adapter)}.sqlite3") : target
294
323
  else
295
- database_name || raise(ConfigurationError, "scratch database name is required")
324
+ target = database_name || raise(ConfigurationError, "scratch database name is required")
325
+ databases.size > 1 ? "#{target}_#{database_config_name(adapter)}" : target
296
326
  end
297
327
  end
298
328
 
@@ -313,11 +343,13 @@ module KamalBackup
313
343
  config.validate_database_backup
314
344
  config.validate_file_restore_target(file_target)
315
345
 
316
- case database.adapter_name
317
- when "sqlite"
318
- raise ConfigurationError, "scratch SQLite path is required" if sqlite_path.to_s.strip.empty?
319
- else
320
- raise ConfigurationError, "scratch database name is required" if database_name.to_s.strip.empty?
346
+ databases.each do |adapter|
347
+ case adapter.adapter_name
348
+ when "sqlite"
349
+ raise ConfigurationError, "scratch SQLite path is required" if sqlite_path.to_s.strip.empty?
350
+ else
351
+ raise ConfigurationError, "scratch database name is required" if database_name.to_s.strip.empty?
352
+ end
321
353
  end
322
354
  end
323
355
 
@@ -373,7 +405,17 @@ module KamalBackup
373
405
  end
374
406
 
375
407
  def database
376
- @database ||= Databases::Base.build(config, redactor: redactor)
408
+ databases.first
409
+ end
410
+
411
+ def databases
412
+ @databases ||= begin
413
+ if @database
414
+ Array(@database)
415
+ else
416
+ config.databases.map { |database_config| Databases::Base.build(database_config, redactor: redactor) }
417
+ end
418
+ end
377
419
  end
378
420
 
379
421
  def resolve_snapshot(argument, tags:)
@@ -218,15 +218,23 @@ module KamalBackup
218
218
 
219
219
  def shared_config_template
220
220
  <<~YAML
221
+ app: your-app
221
222
  accessory: backup
222
- app_name: your-app
223
- database_adapter: postgres
224
- database_url: postgres://your-app@your-db:5432/your_app_production
225
- backup_paths:
223
+ databases:
224
+ - name: app
225
+ adapter: postgres
226
+ url: postgres://your-app@your-db:5432/your_app_production
227
+ password:
228
+ secret: DATABASE_PASSWORD
229
+ paths:
226
230
  - /data/storage
227
- restic_repository: s3:https://s3.example.com/your-app-backups
228
- restic_init_if_missing: true
229
- backup_schedule_seconds: 86400
231
+ restic:
232
+ repository: s3:https://s3.example.com/your-app-backups
233
+ password:
234
+ secret: RESTIC_PASSWORD
235
+ init_if_missing: true
236
+ backup:
237
+ schedule: 1d
230
238
  YAML
231
239
  end
232
240
 
@@ -240,7 +248,7 @@ module KamalBackup
240
248
  - config/kamal-backup.yml:/app/config/kamal-backup.yml:ro
241
249
  env:
242
250
  secret:
243
- - PGPASSWORD
251
+ - DATABASE_PASSWORD
244
252
  - RESTIC_PASSWORD
245
253
  - AWS_ACCESS_KEY_ID
246
254
  - AWS_SECRET_ACCESS_KEY
@@ -450,7 +458,7 @@ module KamalBackup
450
458
  puts
451
459
  puts deploy_snippet
452
460
  puts
453
- puts "The accessory runs scheduled database and Active Storage backups with backup_schedule_seconds."
461
+ puts "The accessory runs scheduled database and file backups with backup.schedule."
454
462
  puts "For most Rails apps, restore local and drill local can infer the development database, Active Storage path, and tmp state directory."
455
463
  puts "Local restore and drill also require the restic binary on your machine."
456
464
  puts "Create config/kamal-backup.local.yml only if you need to override those local defaults."
@@ -17,42 +17,130 @@ module KamalBackup
17
17
  SHARED_CONFIG_PATH = "config/kamal-backup.yml"
18
18
  LOCAL_CONFIG_PATH = "config/kamal-backup.local.yml"
19
19
  DEFAULT_CONFIG_PATHS = [SHARED_CONFIG_PATH, LOCAL_CONFIG_PATH].freeze
20
- YAML_KEY_ALIASES = {
21
- "app_name" => "APP_NAME",
22
- "database_adapter" => "DATABASE_ADAPTER",
23
- "database_url" => "DATABASE_URL",
24
- "sqlite_database_path" => "SQLITE_DATABASE_PATH",
25
- "backup_paths" => "BACKUP_PATHS",
26
- "local_restore_source_paths" => "LOCAL_RESTORE_SOURCE_PATHS",
27
- "accessory" => "KAMAL_BACKUP_ACCESSORY",
28
- "restic_repository" => "RESTIC_REPOSITORY",
29
- "restic_repository_file" => "RESTIC_REPOSITORY_FILE",
30
- "restic_password" => "RESTIC_PASSWORD",
31
- "restic_password_file" => "RESTIC_PASSWORD_FILE",
32
- "restic_password_command" => "RESTIC_PASSWORD_COMMAND",
33
- "restic_init_if_missing" => "RESTIC_INIT_IF_MISSING",
34
- "restic_check_after_backup" => "RESTIC_CHECK_AFTER_BACKUP",
35
- "restic_check_read_data_subset" => "RESTIC_CHECK_READ_DATA_SUBSET",
36
- "restic_forget_after_backup" => "RESTIC_FORGET_AFTER_BACKUP",
37
- "restic_keep_last" => "RESTIC_KEEP_LAST",
38
- "restic_keep_daily" => "RESTIC_KEEP_DAILY",
39
- "restic_keep_weekly" => "RESTIC_KEEP_WEEKLY",
40
- "restic_keep_monthly" => "RESTIC_KEEP_MONTHLY",
41
- "restic_keep_yearly" => "RESTIC_KEEP_YEARLY",
42
- "backup_schedule_seconds" => "BACKUP_SCHEDULE_SECONDS",
43
- "backup_start_delay_seconds" => "BACKUP_START_DELAY_SECONDS",
44
- "state_dir" => "KAMAL_BACKUP_STATE_DIR",
45
- "allow_suspicious_paths" => "KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS",
46
- "pgpassword" => "PGPASSWORD",
47
- "mysql_pwd" => "MYSQL_PWD"
48
- }.freeze
20
+ TOP_LEVEL_YAML_KEYS = %w[app accessory databases paths restore_from restic backup state].freeze
21
+ LEGACY_YAML_KEYS = %w[
22
+ app_name
23
+ database_adapter
24
+ database_url
25
+ sqlite_database_path
26
+ backup_paths
27
+ local_restore_source_paths
28
+ restic_repository
29
+ restic_repository_file
30
+ restic_password
31
+ restic_password_file
32
+ restic_password_command
33
+ restic_init_if_missing
34
+ restic_check_after_backup
35
+ restic_check_read_data_subset
36
+ restic_forget_after_backup
37
+ restic_keep_last
38
+ restic_keep_daily
39
+ restic_keep_weekly
40
+ restic_keep_monthly
41
+ restic_keep_yearly
42
+ backup_schedule_seconds
43
+ backup_start_delay_seconds
44
+ state_dir
45
+ allow_suspicious_paths
46
+ pgpassword
47
+ mysql_pwd
48
+ ].freeze
49
+ ConfigData = Struct.new(:env, :database_definitions, :path_definitions, :restore_from_definitions, keyword_init: true) do
50
+ def self.empty
51
+ new(env: {}, database_definitions: nil, path_definitions: nil, restore_from_definitions: nil)
52
+ end
53
+ end
54
+
55
+ class DatabaseSource
56
+ CONNECTION_KEYS = %w[
57
+ DATABASE_URL
58
+ SQLITE_DATABASE_PATH
59
+ PGHOST
60
+ PGPORT
61
+ PGUSER
62
+ PGPASSWORD
63
+ PGDATABASE
64
+ PGSSLMODE
65
+ PGSSLROOTCERT
66
+ PGSSLCERT
67
+ PGSSLKEY
68
+ PGCONNECT_TIMEOUT
69
+ PGSERVICE
70
+ PGPASSFILE
71
+ MYSQL_HOST
72
+ MYSQL_PORT
73
+ MYSQL_USER
74
+ MYSQL_PWD
75
+ MYSQL_PASSWORD
76
+ MYSQL_DATABASE
77
+ MARIADB_HOST
78
+ MARIADB_PORT
79
+ MARIADB_USER
80
+ MARIADB_PASSWORD
81
+ MARIADB_DATABASE
82
+ ].freeze
83
+
84
+ attr_reader :missing_secrets, :name, :parent
85
+
86
+ def initialize(parent:, name:, adapter:, env:, structured:, missing_secrets: [])
87
+ @parent = parent
88
+ @name = name.to_s
89
+ @adapter = adapter
90
+ @env = env.transform_keys(&:to_s)
91
+ @structured = structured
92
+ @missing_secrets = Array(missing_secrets)
93
+ end
94
+
95
+ def app_name
96
+ parent.app_name
97
+ end
98
+
99
+ def database_name
100
+ name.empty? ? "app" : name
101
+ end
102
+
103
+ def database_adapter
104
+ @adapter || parent.send(:legacy_database_adapter)
105
+ end
106
+
107
+ def value(key)
108
+ raw =
109
+ if @env.key?(key)
110
+ @env[key]
111
+ elsif @structured && CONNECTION_KEYS.include?(key)
112
+ nil
113
+ else
114
+ parent.value(key)
115
+ end
116
+
117
+ stripped = raw.to_s.strip
118
+ stripped.empty? ? nil : stripped
119
+ end
120
+
121
+ def required_value(key)
122
+ value(key) || raise(ConfigurationError, "#{key} is required for database #{database_name}")
123
+ end
124
+
125
+ def validate_database_restore_target(target)
126
+ parent.validate_database_restore_target(target)
127
+ end
128
+
129
+ def validate_local_database_restore_target(target)
130
+ parent.validate_local_database_restore_target(target)
131
+ end
132
+ end
49
133
 
50
134
  attr_reader :env
51
135
 
52
136
  def initialize(env: ENV, cwd: Dir.pwd, defaults: {}, config_paths: nil, load_project_defaults: true)
53
137
  raw_env = env.to_h
54
138
  base = load_project_defaults ? project_defaults(cwd: cwd) : {}
55
- @env = base.merge(defaults.to_h).merge(load_config_files(raw_env, cwd: cwd, paths: config_paths)).merge(raw_env)
139
+ config_data = load_config_files(raw_env, cwd: cwd, paths: config_paths)
140
+ @database_definitions = config_data.database_definitions
141
+ @path_definitions = config_data.path_definitions
142
+ @restore_from_definitions = config_data.restore_from_definitions
143
+ @env = base.merge(defaults.to_h).merge(config_data.env).merge(raw_env)
56
144
  end
57
145
 
58
146
  def app_name
@@ -132,14 +220,18 @@ module KamalBackup
132
220
  end
133
221
 
134
222
  def backup_paths
135
- split_paths(value("BACKUP_PATHS"))
223
+ if path_definitions?
224
+ @path_definitions
225
+ else
226
+ legacy_backup_paths
227
+ end
136
228
  end
137
229
 
138
230
  def local_restore_source_paths
139
- if raw = value("LOCAL_RESTORE_SOURCE_PATHS")
140
- split_paths(raw)
231
+ if path_definitions?
232
+ @restore_from_definitions || legacy_local_restore_source_paths || backup_paths
141
233
  else
142
- backup_paths
234
+ legacy_local_restore_source_paths || backup_paths
143
235
  end
144
236
  end
145
237
 
@@ -150,7 +242,7 @@ module KamalBackup
150
242
  if source_paths.size == target_paths.size
151
243
  source_paths.zip(target_paths)
152
244
  else
153
- raise ConfigurationError, "LOCAL_RESTORE_SOURCE_PATHS must contain the same number of paths as BACKUP_PATHS"
245
+ raise ConfigurationError, "local restore source paths must contain the same number of paths as file paths"
154
246
  end
155
247
  end
156
248
 
@@ -160,12 +252,43 @@ module KamalBackup
160
252
  end
161
253
 
162
254
  def database_adapter
163
- if explicit = value("DATABASE_ADAPTER")
164
- normalize_adapter(explicit)
165
- elsif adapter = adapter_from_database_url
166
- adapter
167
- elsif value("SQLITE_DATABASE_PATH")
168
- "sqlite"
255
+ if database_definitions?
256
+ databases.first&.database_adapter
257
+ else
258
+ legacy_database_adapter
259
+ end
260
+ end
261
+
262
+ def database_name
263
+ "app"
264
+ end
265
+
266
+ def databases
267
+ @databases ||= begin
268
+ if database_definitions?
269
+ @database_definitions.map do |definition|
270
+ DatabaseSource.new(
271
+ parent: self,
272
+ name: definition.fetch(:name),
273
+ adapter: definition.fetch(:adapter),
274
+ env: definition.fetch(:env),
275
+ structured: true,
276
+ missing_secrets: definition.fetch(:missing_secrets, [])
277
+ )
278
+ end
279
+ elsif legacy_database_adapter
280
+ [
281
+ DatabaseSource.new(
282
+ parent: self,
283
+ name: database_name,
284
+ adapter: legacy_database_adapter,
285
+ env: {},
286
+ structured: false
287
+ )
288
+ ]
289
+ else
290
+ []
291
+ end
169
292
  end
170
293
  end
171
294
 
@@ -207,28 +330,33 @@ module KamalBackup
207
330
  end
208
331
 
209
332
  def validate_database_backup(check_files: true)
210
- case database_adapter
211
- when "postgres"
212
- unless value("DATABASE_URL") || value("PGDATABASE")
213
- raise ConfigurationError, "PostgreSQL backup requires DATABASE_URL or PGDATABASE/libpq environment"
333
+ raise ConfigurationError, "databases must contain at least one database" if databases.empty?
334
+
335
+ databases.each do |database|
336
+ unless database.missing_secrets.empty?
337
+ raise ConfigurationError, "database #{database.database_name} requires missing secret #{database.missing_secrets.join(", ")}"
214
338
  end
215
- when "mysql"
216
- unless value("DATABASE_URL") || value("MYSQL_DATABASE") || value("MARIADB_DATABASE")
217
- raise ConfigurationError, "MySQL backup requires DATABASE_URL or MYSQL_DATABASE/MARIADB_DATABASE"
339
+
340
+ case database.database_adapter
341
+ when "postgres"
342
+ unless database.value("DATABASE_URL") || database.value("PGDATABASE")
343
+ raise ConfigurationError, "PostgreSQL database #{database.database_name} requires url or PGDATABASE/libpq environment"
344
+ end
345
+ when "mysql"
346
+ unless database.value("DATABASE_URL") || database.value("MYSQL_DATABASE") || database.value("MARIADB_DATABASE")
347
+ raise ConfigurationError, "MySQL database #{database.database_name} requires url or MYSQL_DATABASE/MARIADB_DATABASE"
348
+ end
349
+ when "sqlite"
350
+ path = database.required_value("SQLITE_DATABASE_PATH")
351
+ raise ConfigurationError, "SQLite database #{database.database_name} does not exist: #{path}" if check_files && !File.file?(path)
352
+ else
353
+ raise ConfigurationError, "database #{database.database_name} adapter is required and must be postgres, mysql, or sqlite"
218
354
  end
219
- when "sqlite"
220
- path = required_value("SQLITE_DATABASE_PATH")
221
- raise ConfigurationError, "SQLITE_DATABASE_PATH does not exist: #{path}" if check_files && !File.file?(path)
222
- else
223
- raise ConfigurationError, "DATABASE_ADAPTER is required or must be detectable from DATABASE_URL/SQLITE_DATABASE_PATH"
224
355
  end
225
356
  end
226
357
 
227
358
  def validate_backup_paths(check_files: true)
228
- paths = backup_paths
229
- raise ConfigurationError, "BACKUP_PATHS must contain at least one path" if paths.empty?
230
-
231
- paths.each do |path|
359
+ backup_paths.each do |path|
232
360
  expanded = File.expand_path(path)
233
361
  if SUSPICIOUS_BACKUP_PATHS.include?(expanded) && !allow_suspicious_backup_paths?
234
362
  raise ConfigurationError, "refusing suspicious backup path #{expanded}; set KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS=true to override"
@@ -302,10 +430,14 @@ module KamalBackup
302
430
  end
303
431
 
304
432
  def load_config_files(raw_env, cwd:, paths:)
305
- config_paths(raw_env, cwd: cwd, paths: paths).each_with_object({}) do |path, merged|
433
+ config_paths(raw_env, cwd: cwd, paths: paths).each_with_object(ConfigData.empty) do |path, merged|
306
434
  next unless File.file?(path)
307
435
 
308
- merged.merge!(normalize_config_file(path))
436
+ data = normalize_config_file(path, raw_env: raw_env)
437
+ merged.env.merge!(data.env)
438
+ merged.database_definitions = data.database_definitions if data.database_definitions
439
+ merged.path_definitions = data.path_definitions if data.path_definitions
440
+ merged.restore_from_definitions = data.restore_from_definitions if data.restore_from_definitions
309
441
  end
310
442
  end
311
443
 
@@ -343,25 +475,51 @@ module KamalBackup
343
475
  raise ConfigurationError, "RESTIC_PASSWORD, RESTIC_PASSWORD_FILE, or RESTIC_PASSWORD_COMMAND is required"
344
476
  end
345
477
 
346
- def normalize_config_file(path)
478
+ def normalize_config_file(path, raw_env:)
347
479
  data = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
348
- return {} if data.nil?
480
+ return ConfigData.empty if data.nil?
349
481
 
350
482
  unless data.is_a?(Hash)
351
483
  raise ConfigurationError, "#{path} must contain a YAML mapping"
352
484
  end
353
485
 
354
- data.each_with_object({}) do |(raw_key, raw_value), result|
355
- key = normalize_yaml_key(raw_key)
356
- result[key] = normalize_yaml_value(raw_value)
486
+ result = ConfigData.empty
487
+ data.each do |raw_key, raw_value|
488
+ key = raw_key.to_s
489
+ validate_top_level_yaml_key!(path, key)
490
+
491
+ case key
492
+ when "app"
493
+ result.env["APP_NAME"] = normalize_yaml_value(raw_value)
494
+ when "accessory"
495
+ result.env["KAMAL_BACKUP_ACCESSORY"] = normalize_yaml_value(raw_value)
496
+ when "databases"
497
+ result.database_definitions = normalize_yaml_databases(raw_value, raw_env: raw_env, path: path)
498
+ when "paths"
499
+ result.path_definitions = normalize_yaml_paths(raw_value, "#{path} paths")
500
+ when "restore_from"
501
+ result.restore_from_definitions = normalize_yaml_paths(raw_value, "#{path} restore_from")
502
+ when "restic"
503
+ result.env.merge!(normalize_yaml_restic(raw_value, raw_env: raw_env, path: path))
504
+ when "backup"
505
+ result.env.merge!(normalize_yaml_backup(raw_value, path: path))
506
+ when "state"
507
+ result.env.merge!(normalize_yaml_state(raw_value, path: path))
508
+ end
357
509
  end
510
+ result
358
511
  rescue Psych::SyntaxError => e
359
512
  raise ConfigurationError, "invalid YAML in #{path}: #{e.message}"
360
513
  end
361
514
 
362
- def normalize_yaml_key(raw_key)
363
- key = raw_key.to_s
364
- YAML_KEY_ALIASES[key] || key.upcase
515
+ def validate_top_level_yaml_key!(path, key)
516
+ if LEGACY_YAML_KEYS.include?(key)
517
+ raise ConfigurationError, "#{path} uses legacy key #{key}; use databases, paths, restic, and backup instead. See the upgrading guide for the 0.3 config migration."
518
+ end
519
+
520
+ return if TOP_LEVEL_YAML_KEYS.include?(key)
521
+
522
+ raise ConfigurationError, "#{path} contains unknown key #{key.inspect}; expected #{TOP_LEVEL_YAML_KEYS.join(", ")}"
365
523
  end
366
524
 
367
525
  def normalize_yaml_value(raw_value)
@@ -375,6 +533,211 @@ module KamalBackup
375
533
  end
376
534
  end
377
535
 
536
+ def normalize_yaml_databases(raw_value, raw_env:, path:)
537
+ entries = require_array(raw_value, "#{path} databases")
538
+ entries.map.with_index(1) do |entry, index|
539
+ hash = require_mapping(entry, "#{path} databases[#{index}]")
540
+ name = required_yaml_scalar(hash, "name", "#{path} databases[#{index}]")
541
+ adapter = normalize_adapter(required_yaml_scalar(hash, "adapter", "#{path} databases[#{index}]"))
542
+ raise ConfigurationError, "#{path} databases[#{index}] adapter must be postgres, mysql, or sqlite" unless adapter
543
+
544
+ env = {}
545
+ missing_secrets = []
546
+ case adapter
547
+ when "postgres"
548
+ if hash.key?("url")
549
+ env["DATABASE_URL"] = resolve_yaml_value(hash["url"], raw_env: raw_env, context: "#{path} databases[#{index}].url")
550
+ missing_secrets.concat(missing_yaml_secrets(hash["url"], raw_env: raw_env))
551
+ end
552
+ if hash.key?("password")
553
+ env["PGPASSWORD"] = resolve_yaml_value(hash["password"], raw_env: raw_env, context: "#{path} databases[#{index}].password")
554
+ missing_secrets.concat(missing_yaml_secrets(hash["password"], raw_env: raw_env))
555
+ end
556
+ when "mysql"
557
+ if hash.key?("url")
558
+ env["DATABASE_URL"] = resolve_yaml_value(hash["url"], raw_env: raw_env, context: "#{path} databases[#{index}].url")
559
+ missing_secrets.concat(missing_yaml_secrets(hash["url"], raw_env: raw_env))
560
+ end
561
+ if hash.key?("password")
562
+ env["MYSQL_PWD"] = resolve_yaml_value(hash["password"], raw_env: raw_env, context: "#{path} databases[#{index}].password")
563
+ missing_secrets.concat(missing_yaml_secrets(hash["password"], raw_env: raw_env))
564
+ end
565
+ when "sqlite"
566
+ sqlite_path = hash.key?("path") ? hash["path"] : hash["database"]
567
+ if sqlite_path
568
+ env["SQLITE_DATABASE_PATH"] = resolve_yaml_value(sqlite_path, raw_env: raw_env, context: "#{path} databases[#{index}].path")
569
+ missing_secrets.concat(missing_yaml_secrets(sqlite_path, raw_env: raw_env))
570
+ end
571
+ end
572
+
573
+ {
574
+ name: name,
575
+ adapter: adapter,
576
+ env: env.compact,
577
+ missing_secrets: missing_secrets
578
+ }
579
+ end
580
+ end
581
+
582
+ def normalize_yaml_restic(raw_value, raw_env:, path:)
583
+ hash = require_mapping(raw_value, "#{path} restic")
584
+ env = {}
585
+
586
+ env["RESTIC_REPOSITORY"] = resolve_yaml_value(hash["repository"], raw_env: raw_env, context: "#{path} restic.repository") if hash.key?("repository")
587
+ env["RESTIC_REPOSITORY_FILE"] = normalize_yaml_value(hash["repository_file"]) if hash.key?("repository_file")
588
+
589
+ if hash.key?("password")
590
+ normalize_yaml_restic_password(hash["password"], raw_env: raw_env, path: path).each do |key, value|
591
+ env[key] = value
592
+ end
593
+ end
594
+
595
+ {
596
+ "init_if_missing" => "RESTIC_INIT_IF_MISSING",
597
+ "check_after_backup" => "RESTIC_CHECK_AFTER_BACKUP",
598
+ "check_read_data_subset" => "RESTIC_CHECK_READ_DATA_SUBSET",
599
+ "forget_after_backup" => "RESTIC_FORGET_AFTER_BACKUP"
600
+ }.each do |source, target|
601
+ env[target] = normalize_yaml_value(hash[source]) if hash.key?(source)
602
+ end
603
+
604
+ if hash.key?("retention")
605
+ retention = require_mapping(hash["retention"], "#{path} restic.retention")
606
+ {
607
+ "keep_last" => "RESTIC_KEEP_LAST",
608
+ "keep_daily" => "RESTIC_KEEP_DAILY",
609
+ "keep_weekly" => "RESTIC_KEEP_WEEKLY",
610
+ "keep_monthly" => "RESTIC_KEEP_MONTHLY",
611
+ "keep_yearly" => "RESTIC_KEEP_YEARLY"
612
+ }.each do |source, target|
613
+ env[target] = normalize_yaml_value(retention[source]) if retention.key?(source)
614
+ end
615
+ end
616
+
617
+ env.compact
618
+ end
619
+
620
+ def normalize_yaml_restic_password(raw_value, raw_env:, path:)
621
+ case raw_value
622
+ when Hash
623
+ hash = stringify_keys(raw_value)
624
+ if hash.key?("secret")
625
+ { "RESTIC_PASSWORD" => resolve_yaml_value(hash, raw_env: raw_env, context: "#{path} restic.password") }
626
+ elsif hash.key?("file")
627
+ { "RESTIC_PASSWORD_FILE" => normalize_yaml_value(hash["file"]) }
628
+ elsif hash.key?("command")
629
+ { "RESTIC_PASSWORD_COMMAND" => normalize_yaml_value(hash["command"]) }
630
+ else
631
+ raise ConfigurationError, "#{path} restic.password must use secret, file, or command"
632
+ end
633
+ else
634
+ { "RESTIC_PASSWORD" => normalize_yaml_value(raw_value) }
635
+ end
636
+ end
637
+
638
+ def normalize_yaml_backup(raw_value, path:)
639
+ hash = require_mapping(raw_value, "#{path} backup")
640
+ env = {}
641
+ env["BACKUP_SCHEDULE_SECONDS"] = normalize_duration(hash["schedule"], "#{path} backup.schedule") if hash.key?("schedule")
642
+ env.compact
643
+ end
644
+
645
+ def normalize_yaml_state(raw_value, path:)
646
+ hash = require_mapping(raw_value, "#{path} state")
647
+ env = {}
648
+ env["KAMAL_BACKUP_STATE_DIR"] = normalize_yaml_value(hash["path"]) if hash.key?("path")
649
+ env.compact
650
+ end
651
+
652
+ def normalize_yaml_paths(raw_value, context)
653
+ case raw_value
654
+ when Array
655
+ raw_value.map { |path| normalize_yaml_path(path, context) }.reject(&:empty?)
656
+ when NilClass
657
+ []
658
+ else
659
+ [normalize_yaml_path(raw_value, context)].reject(&:empty?)
660
+ end
661
+ end
662
+
663
+ def normalize_yaml_path(raw_value, context)
664
+ if raw_value.is_a?(Hash) || raw_value.is_a?(Array)
665
+ raise ConfigurationError, "#{context} entries must be path strings"
666
+ end
667
+
668
+ normalize_yaml_value(raw_value)
669
+ end
670
+
671
+ def resolve_yaml_value(raw_value, raw_env:, context:)
672
+ case raw_value
673
+ when Hash
674
+ hash = stringify_keys(raw_value)
675
+ unless hash.keys == ["secret"]
676
+ raise ConfigurationError, "#{context} must be a scalar value or { secret: NAME }"
677
+ end
678
+
679
+ secret_name = normalize_yaml_value(hash.fetch("secret"))
680
+ raw_env[secret_name]
681
+ else
682
+ normalize_yaml_value(raw_value)
683
+ end
684
+ end
685
+
686
+ def missing_yaml_secrets(raw_value, raw_env:)
687
+ return [] unless raw_value.is_a?(Hash)
688
+
689
+ hash = stringify_keys(raw_value)
690
+ return [] unless hash.key?("secret")
691
+
692
+ secret_name = normalize_yaml_value(hash.fetch("secret"))
693
+ raw_env[secret_name].to_s.strip.empty? ? [secret_name] : []
694
+ end
695
+
696
+ def normalize_duration(raw_value, context)
697
+ value = normalize_yaml_value(raw_value)
698
+ raise ConfigurationError, "#{context} is required" if value.to_s.empty?
699
+
700
+ return value if value.match?(/\A\d+\z/)
701
+
702
+ match = value.match(/\A(\d+)\s*([smhdw])\z/i)
703
+ unless match
704
+ raise ConfigurationError, "#{context} must be seconds or a duration like 30m, 6h, or 1d"
705
+ end
706
+
707
+ amount = match[1].to_i
708
+ multiplier = {
709
+ "s" => 1,
710
+ "m" => 60,
711
+ "h" => 3600,
712
+ "d" => 86_400,
713
+ "w" => 604_800
714
+ }.fetch(match[2].downcase)
715
+ (amount * multiplier).to_s
716
+ end
717
+
718
+ def require_array(value, context)
719
+ return value if value.is_a?(Array)
720
+
721
+ raise ConfigurationError, "#{context} must be a YAML sequence"
722
+ end
723
+
724
+ def require_mapping(value, context)
725
+ raise ConfigurationError, "#{context} must be a YAML mapping" unless value.is_a?(Hash)
726
+
727
+ stringify_keys(value)
728
+ end
729
+
730
+ def required_yaml_scalar(hash, key, context)
731
+ value = normalize_yaml_value(hash[key])
732
+ raise ConfigurationError, "#{context} #{key} is required" if value.to_s.empty?
733
+
734
+ value
735
+ end
736
+
737
+ def stringify_keys(hash)
738
+ hash.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
739
+ end
740
+
378
741
  def validate_local_machine_environment
379
742
  if environment = local_restore_environment
380
743
  key, value = environment
@@ -387,7 +750,6 @@ module KamalBackup
387
750
 
388
751
  def validate_local_machine_paths
389
752
  path_pairs = local_restore_path_pairs
390
- raise ConfigurationError, "BACKUP_PATHS must contain at least one path" if path_pairs.empty?
391
753
 
392
754
  path_pairs.each do |_source_path, target_path|
393
755
  expanded = File.expand_path(target_path)
@@ -420,6 +782,24 @@ module KamalBackup
420
782
  end
421
783
  end
422
784
 
785
+ def database_definitions?
786
+ !@database_definitions.nil?
787
+ end
788
+
789
+ def path_definitions?
790
+ !@path_definitions.nil?
791
+ end
792
+
793
+ def legacy_database_adapter
794
+ if explicit = value("DATABASE_ADAPTER")
795
+ normalize_adapter(explicit)
796
+ elsif adapter = adapter_from_database_url
797
+ adapter
798
+ elsif value("SQLITE_DATABASE_PATH")
799
+ "sqlite"
800
+ end
801
+ end
802
+
423
803
  def adapter_from_database_url
424
804
  if url = value("DATABASE_URL")
425
805
  normalize_adapter(URI.parse(url).scheme)
@@ -436,13 +816,25 @@ module KamalBackup
436
816
  end
437
817
 
438
818
  def source_database_targets
439
- [
440
- value("DATABASE_URL"),
441
- value("SQLITE_DATABASE_PATH"),
442
- value("PGDATABASE"),
443
- value("MYSQL_DATABASE"),
444
- value("MARIADB_DATABASE")
445
- ].compact
819
+ databases.flat_map do |database|
820
+ [
821
+ database.value("DATABASE_URL"),
822
+ database.value("SQLITE_DATABASE_PATH"),
823
+ database.value("PGDATABASE"),
824
+ database.value("MYSQL_DATABASE"),
825
+ database.value("MARIADB_DATABASE")
826
+ ]
827
+ end.compact
828
+ end
829
+
830
+ def legacy_backup_paths
831
+ split_paths(value("BACKUP_PATHS"))
832
+ end
833
+
834
+ def legacy_local_restore_source_paths
835
+ if raw = value("LOCAL_RESTORE_SOURCE_PATHS")
836
+ split_paths(raw)
837
+ end
446
838
  end
447
839
 
448
840
  def split_paths(raw)
@@ -43,11 +43,12 @@ module KamalBackup
43
43
 
44
44
  def database_filename(timestamp)
45
45
  app = config.app_name.gsub(/[^A-Za-z0-9_.-]+/, "-")
46
- "databases-#{app}-#{adapter_name}-#{timestamp}.#{dump_extension}"
46
+ database = config.database_name.gsub(/[^A-Za-z0-9_.-]+/, "-")
47
+ "databases-#{app}-#{database}-#{adapter_name}-#{timestamp}.#{dump_extension}"
47
48
  end
48
49
 
49
50
  def backup_tags(timestamp)
50
- ["type:database", "adapter:#{adapter_name}", "run:#{timestamp}"]
51
+ ["type:database", "database:#{config.database_name}", "adapter:#{adapter_name}", "run:#{timestamp}"]
51
52
  end
52
53
 
53
54
  def adapter_name
@@ -68,7 +68,9 @@ module KamalBackup
68
68
 
69
69
  def current_connection
70
70
  if value("DATABASE_URL")
71
- parse_url(value("DATABASE_URL"))
71
+ parse_url(value("DATABASE_URL")).tap do |connection|
72
+ connection[:password] ||= value("MYSQL_PWD") || value("MYSQL_PASSWORD") || value("MARIADB_PASSWORD")
73
+ end
72
74
  else
73
75
  connection_from_env("")
74
76
  end
@@ -68,7 +68,9 @@ module KamalBackup
68
68
 
69
69
  def current_connection
70
70
  if value("DATABASE_URL")
71
- connection_from_url(value("DATABASE_URL"), "DATABASE_URL")
71
+ connection_from_url(value("DATABASE_URL"), "DATABASE_URL").tap do |connection|
72
+ connection["PGPASSWORD"] ||= value("PGPASSWORD") if value("PGPASSWORD")
73
+ end
72
74
  else
73
75
  connection = prefixed_env("", SOURCE_ENV_KEYS)
74
76
  raise ConfigurationError, "DATABASE_URL or PGDATABASE is required for PostgreSQL restore" unless connection["PGDATABASE"]
@@ -18,11 +18,14 @@ module KamalBackup
18
18
  app_name: @config.app_name,
19
19
  generated_at: Time.now.utc.iso8601,
20
20
  database_adapter: @config.database_adapter,
21
+ databases: @config.databases.map { |database| { name: database.database_name, adapter: database.database_adapter } },
21
22
  restic_repository: @redactor.redact_string(@config.restic_repository.to_s),
22
23
  backup_paths: @config.backup_paths,
24
+ paths: @config.backup_paths,
23
25
  forget_after_backup: @config.forget_after_backup?,
24
26
  retention: @config.retention,
25
27
  latest_database_backup: latest_snapshot_summary(["type:database"]),
28
+ latest_database_backups: latest_database_backups,
26
29
  latest_file_backup: latest_snapshot_summary(["type:files"]),
27
30
  last_restic_check: last_check,
28
31
  last_restore_drill: last_restore_drill,
@@ -50,6 +53,16 @@ module KamalBackup
50
53
  { error: @redactor.redact_string(e.message) }
51
54
  end
52
55
 
56
+ def latest_database_backups
57
+ @config.databases.each_with_object({}) do |database, backups|
58
+ backups[database.database_name] = latest_snapshot_summary([
59
+ "type:database",
60
+ "database:#{database.database_name}",
61
+ "adapter:#{database.database_adapter}"
62
+ ])
63
+ end
64
+ end
65
+
53
66
  def last_check
54
67
  if File.file?(@config.last_check_path)
55
68
  JSON.parse(File.read(@config.last_check_path))
@@ -1,3 +1,4 @@
1
+ require "shellwords"
1
2
  require "yaml"
2
3
  require_relative "command"
3
4
 
@@ -147,10 +148,15 @@ module KamalBackup
147
148
 
148
149
  def parse_secret_output(output)
149
150
  output.to_s.lines.each_with_object({}) do |line, secrets|
150
- key, value = line.chomp.split("=", 2)
151
- next if key.to_s.empty?
151
+ tokens = Shellwords.split(line.chomp)
152
+ tokens.shift if tokens.first == "export"
152
153
 
153
- secrets[key] = value.to_s
154
+ tokens.each do |assignment|
155
+ key, value = assignment.split("=", 2)
156
+ next if key.to_s.empty? || value.nil?
157
+
158
+ secrets[key] = value.to_s
159
+ end
154
160
  end
155
161
  end
156
162
 
@@ -122,14 +122,19 @@ module KamalBackup
122
122
  end
123
123
  end
124
124
 
125
- def database_file(snapshot, adapter)
125
+ def database_file(snapshot, adapter, database_name: nil)
126
126
  legacy_prefix = "databases/#{config.app_name}/#{adapter}/"
127
- flat_prefix = "databases-#{config.app_name.gsub(/[^A-Za-z0-9_.-]+/, "-")}-#{adapter}-"
127
+ app = config.app_name.gsub(/[^A-Za-z0-9_.-]+/, "-")
128
+ database = database_name.to_s.gsub(/[^A-Za-z0-9_.-]+/, "-")
129
+ flat_prefix = "databases-#{app}-#{adapter}-"
130
+ named_flat_prefix = database.empty? ? nil : "databases-#{app}-#{database}-#{adapter}-"
128
131
  ls_json(snapshot).find do |entry|
129
132
  next false unless entry["type"] == "file"
130
133
 
131
134
  normalized = entry["path"].to_s.sub(%r{\A/+}, "")
132
- normalized.start_with?(legacy_prefix) || File.basename(normalized).start_with?(flat_prefix)
135
+ normalized.start_with?(legacy_prefix) ||
136
+ File.basename(normalized).start_with?(flat_prefix) ||
137
+ (named_flat_prefix && File.basename(normalized).start_with?(named_flat_prefix))
133
138
  end&.fetch("path")
134
139
  end
135
140
 
@@ -1,3 +1,3 @@
1
1
  module KamalBackup
2
- VERSION = "0.2.10"
2
+ VERSION = "0.3.0.beta2"
3
3
  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.2.10
4
+ version: 0.3.0.beta2
5
5
  platform: ruby
6
6
  authors:
7
7
  - crmne
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-13 00:00:00.000000000 Z
11
+ date: 2026-05-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -74,7 +74,13 @@ metadata:
74
74
  source_code_uri: https://github.com/crmne/kamal-backup
75
75
  changelog_uri: https://github.com/crmne/kamal-backup/releases
76
76
  rubygems_mfa_required: 'true'
77
- post_install_message:
77
+ post_install_message: |
78
+ kamal-backup 0.3 changes config/kamal-backup.yml.
79
+
80
+ If you are upgrading from 0.2, migrate the old flat YAML keys to the new grouped shape:
81
+ https://kamal-backup.dev/upgrading/#upgrading-to-03
82
+
83
+ Run `bundle exec kamal-backup validate` before rebooting the backup accessory.
78
84
  rdoc_options: []
79
85
  require_paths:
80
86
  - lib