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 +4 -4
- data/README.md +28 -19
- data/lib/kamal_backup/app.rb +87 -45
- data/lib/kamal_backup/cli.rb +17 -9
- data/lib/kamal_backup/config.rb +467 -75
- data/lib/kamal_backup/databases/base.rb +3 -2
- data/lib/kamal_backup/databases/mysql.rb +3 -1
- data/lib/kamal_backup/databases/postgres.rb +3 -1
- data/lib/kamal_backup/evidence.rb +13 -0
- data/lib/kamal_backup/kamal_bridge.rb +9 -3
- data/lib/kamal_backup/restic.rb +8 -3
- data/lib/kamal_backup/version.rb +1 -1
- metadata +9 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 55945234709fcf743588fe6fadf7873ff43dcc832a2bc8303e42ba67e15c13db
|
|
4
|
+
data.tar.gz: a3e05e3428bdcceafa37d2dc2f0cdea53d50e204f7c9345e38308be0085ba9ea
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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>
|
|
7
|
+
<strong>Add scheduled Rails backups to Kamal with one accessory.</strong>
|
|
8
8
|
|
|
9
9
|
[](https://rubygems.org/gems/kamal-backup)
|
|
10
10
|
[](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
|
-
-
|
|
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`
|
|
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
|
-
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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 `
|
|
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:**
|
|
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,
|
|
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`,
|
|
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
|
|
146
|
+
Use `bin/release 0.2.9 --no-push` to prepare the commit locally without publishing.
|
|
138
147
|
|
|
139
148
|
## License
|
|
140
149
|
|
data/lib/kamal_backup/app.rb
CHANGED
|
@@ -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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
result
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
result
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
result
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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:)
|
data/lib/kamal_backup/cli.rb
CHANGED
|
@@ -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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
-
|
|
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
|
|
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."
|
data/lib/kamal_backup/config.rb
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
140
|
-
|
|
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, "
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
151
|
-
|
|
151
|
+
tokens = Shellwords.split(line.chomp)
|
|
152
|
+
tokens.shift if tokens.first == "export"
|
|
152
153
|
|
|
153
|
-
|
|
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
|
|
data/lib/kamal_backup/restic.rb
CHANGED
|
@@ -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
|
-
|
|
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) ||
|
|
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
|
|
data/lib/kamal_backup/version.rb
CHANGED
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
|
+
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-
|
|
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
|