kamal-backup 0.1.0.pre.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +345 -0
- data/exe/kamal-backup +7 -0
- data/lib/kamal_backup/cli.rb +156 -0
- data/lib/kamal_backup/command.rb +49 -0
- data/lib/kamal_backup/config.rb +273 -0
- data/lib/kamal_backup/databases/base.rb +74 -0
- data/lib/kamal_backup/databases/mysql.rb +109 -0
- data/lib/kamal_backup/databases/postgres.rb +125 -0
- data/lib/kamal_backup/databases/sqlite.rb +73 -0
- data/lib/kamal_backup/errors.rb +16 -0
- data/lib/kamal_backup/evidence.rb +80 -0
- data/lib/kamal_backup/redactor.rb +53 -0
- data/lib/kamal_backup/restic.rb +239 -0
- data/lib/kamal_backup/scheduler.rb +50 -0
- data/lib/kamal_backup/version.rb +3 -0
- data/lib/kamal_backup.rb +13 -0
- metadata +68 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b7d6d47cf91c1aeb85a212dace05952ea498ac6cd32b375bc528432c6f9fd94e
|
|
4
|
+
data.tar.gz: 8e5e8902264e06c87190be87d46981a2b584a5de7ed1cc2a3a161c76cd50864a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: bc1ea52b549651e2a5dca8df1eb7b39cd79e17c4ee41b90a91901638a3137ac42f0a8e1b143dc131754c784601af806c48527474e8cf95eb45ff4389bdb35c49
|
|
7
|
+
data.tar.gz: dd9e5881f3475359e8a0707c0741dc371bf2894f56ef61e244959d6e42228093b6192ec529b65d9b6e95482192e5c21c3e53600268dc3304f061cf1a621e8d8a
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kamal-backup contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
# kamal-backup
|
|
2
|
+
|
|
3
|
+
`kamal-backup` is a small Docker image for Kamal accessories. It creates encrypted, restic-backed backups for self-hosted apps by backing up database dumps and mounted application files together.
|
|
4
|
+
|
|
5
|
+
The Docker image is the normal production interface. The Ruby gem packages the same `kamal-backup` executable so the image can install it cleanly, and so operators can optionally run the CLI from a laptop for restore drills when they have restic, database clients, and the right environment configured.
|
|
6
|
+
|
|
7
|
+
It is aimed at common Kamal backup needs:
|
|
8
|
+
|
|
9
|
+
- Kamal Postgres backup
|
|
10
|
+
- Kamal MySQL and MariaDB backup
|
|
11
|
+
- Kamal Active Storage backup
|
|
12
|
+
- Kamal restic backup
|
|
13
|
+
- Restore drills and evidence for security reviews such as CASA
|
|
14
|
+
|
|
15
|
+
## What It Backs Up
|
|
16
|
+
|
|
17
|
+
`kamal-backup` handles two data surfaces:
|
|
18
|
+
|
|
19
|
+
1. A logical database dump from PostgreSQL, MySQL/MariaDB, or SQLite.
|
|
20
|
+
2. Mounted application files such as Rails Active Storage.
|
|
21
|
+
|
|
22
|
+
Database backups are logical dumps, not raw database data directories. File backups use one `restic backup` snapshot per run containing all configured mounted paths, so `restore-files latest` restores all file paths from that run.
|
|
23
|
+
|
|
24
|
+
Database dump snapshots are tagged with `kamal-backup`, `app:<name>`, `type:database`, `adapter:<adapter>`, and `run:<timestamp>`. The dump object uses a flat restic stdin filename such as `databases-chatwithwork-postgres-20260422T120000Z.pgdump` because restic stdin backups do not support nested virtual paths consistently.
|
|
25
|
+
|
|
26
|
+
## Quick Start With Kamal
|
|
27
|
+
|
|
28
|
+
Add a backup accessory to your Kamal deploy config:
|
|
29
|
+
|
|
30
|
+
```yaml
|
|
31
|
+
aliases:
|
|
32
|
+
backup: accessory exec backup "kamal-backup backup"
|
|
33
|
+
backup-list: accessory exec backup "kamal-backup list"
|
|
34
|
+
backup-check: accessory exec backup "kamal-backup check"
|
|
35
|
+
backup-evidence: accessory exec backup "kamal-backup evidence"
|
|
36
|
+
backup-logs: accessory logs backup -f
|
|
37
|
+
|
|
38
|
+
accessories:
|
|
39
|
+
backup:
|
|
40
|
+
image: ghcr.io/crmne/kamal-backup:latest
|
|
41
|
+
host: chatwithwork.com
|
|
42
|
+
env:
|
|
43
|
+
clear:
|
|
44
|
+
APP_NAME: chatwithwork
|
|
45
|
+
DATABASE_ADAPTER: postgres
|
|
46
|
+
DATABASE_URL: postgres://chatwithwork@chatwithwork-db:5432/chatwithwork_production
|
|
47
|
+
BACKUP_PATHS: /data/storage
|
|
48
|
+
RESTIC_REPOSITORY: s3:https://s3.example.com/chatwithwork-backups
|
|
49
|
+
RESTIC_INIT_IF_MISSING: "true"
|
|
50
|
+
BACKUP_SCHEDULE_SECONDS: "86400"
|
|
51
|
+
secret:
|
|
52
|
+
- PGPASSWORD
|
|
53
|
+
- RESTIC_PASSWORD
|
|
54
|
+
- AWS_ACCESS_KEY_ID
|
|
55
|
+
- AWS_SECRET_ACCESS_KEY
|
|
56
|
+
volumes:
|
|
57
|
+
- "chatwithwork_storage:/data/storage:ro"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Boot it:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
bin/kamal accessory boot backup
|
|
64
|
+
bin/kamal accessory logs backup
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Run manual commands:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
bin/kamal backup
|
|
71
|
+
bin/kamal backup-list
|
|
72
|
+
bin/kamal backup-evidence
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Commands
|
|
76
|
+
|
|
77
|
+
Commands usually run inside the production backup accessory with `bin/kamal accessory exec backup "kamal-backup <command>"`, or through Kamal aliases such as `bin/kamal backup`. A local gem install is useful when you intentionally want the operator laptop to run restic and database client commands directly.
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
kamal-backup backup
|
|
81
|
+
kamal-backup restore-db [snapshot-or-latest]
|
|
82
|
+
kamal-backup restore-files [snapshot-or-latest] [target-dir]
|
|
83
|
+
kamal-backup list
|
|
84
|
+
kamal-backup check
|
|
85
|
+
kamal-backup evidence
|
|
86
|
+
kamal-backup schedule
|
|
87
|
+
kamal-backup version
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
| Command | What it does |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `backup` | Runs one immediate backup, creating one database snapshot and one file snapshot for all `BACKUP_PATHS`. |
|
|
93
|
+
| `restore-db [snapshot-or-latest]` | Restores a database dump. Defaults to `latest` and requires explicit restore environment. |
|
|
94
|
+
| `restore-files [snapshot-or-latest] [target-dir]` | Restores file paths from a file snapshot. Defaults to `latest /restore/files`. |
|
|
95
|
+
| `list` | Lists restic snapshots for the configured app tags. |
|
|
96
|
+
| `check` | Runs `restic check` and records the latest result for evidence output. |
|
|
97
|
+
| `evidence` | Prints redacted JSON with backup configuration, latest snapshots, check status, and tool versions. |
|
|
98
|
+
| `schedule` | Runs the foreground scheduler loop used by the container default command. |
|
|
99
|
+
| `version` | Prints the gem version. `--version` and `-v` do the same. |
|
|
100
|
+
|
|
101
|
+
The default container command is:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
kamal-backup schedule
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Configuration
|
|
108
|
+
|
|
109
|
+
Required common environment:
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
APP_NAME=chatwithwork
|
|
113
|
+
DATABASE_ADAPTER=postgres
|
|
114
|
+
RESTIC_REPOSITORY=s3:https://s3.example.com/chatwithwork-backups
|
|
115
|
+
RESTIC_PASSWORD=change-me
|
|
116
|
+
BACKUP_PATHS=/data/storage
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`BACKUP_PATHS` accepts colon-separated or newline-separated paths. Every path must exist. Suspicious broad paths such as `/`, `/var`, `/etc`, and `/root` are refused unless `KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS=true`.
|
|
120
|
+
|
|
121
|
+
PostgreSQL:
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
DATABASE_ADAPTER=postgres
|
|
125
|
+
DATABASE_URL=postgres://app@app-db:5432/app_production
|
|
126
|
+
PGPASSWORD=change-me
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
MySQL/MariaDB:
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
DATABASE_ADAPTER=mysql
|
|
133
|
+
DATABASE_URL=mysql2://app@app-mysql:3306/app_production
|
|
134
|
+
MYSQL_PWD=change-me
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
SQLite:
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
DATABASE_ADAPTER=sqlite
|
|
141
|
+
SQLITE_DATABASE_PATH=/data/db/production.sqlite3
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Retention defaults:
|
|
145
|
+
|
|
146
|
+
```sh
|
|
147
|
+
RESTIC_KEEP_LAST=7
|
|
148
|
+
RESTIC_KEEP_DAILY=7
|
|
149
|
+
RESTIC_KEEP_WEEKLY=4
|
|
150
|
+
RESTIC_KEEP_MONTHLY=6
|
|
151
|
+
RESTIC_KEEP_YEARLY=2
|
|
152
|
+
RESTIC_FORGET_AFTER_BACKUP=true
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Set `RESTIC_FORGET_AFTER_BACKUP=false` for append-only repositories, such as a restic REST server started with `--append-only`. In that mode, run retention and prune from the backup server or another trusted maintenance process with delete permissions.
|
|
156
|
+
|
|
157
|
+
Scheduler and checks:
|
|
158
|
+
|
|
159
|
+
```sh
|
|
160
|
+
BACKUP_SCHEDULE_SECONDS=86400
|
|
161
|
+
BACKUP_START_DELAY_SECONDS=0
|
|
162
|
+
RESTIC_CHECK_AFTER_BACKUP=false
|
|
163
|
+
RESTIC_CHECK_READ_DATA_SUBSET=5%
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
For S3-compatible restic repositories, provide the standard restic/AWS variables as Kamal secrets:
|
|
167
|
+
|
|
168
|
+
```sh
|
|
169
|
+
AWS_ACCESS_KEY_ID=...
|
|
170
|
+
AWS_SECRET_ACCESS_KEY=...
|
|
171
|
+
AWS_DEFAULT_REGION=...
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Restore Drills
|
|
175
|
+
|
|
176
|
+
Restores are intentionally hard to run by accident. Every restore command requires:
|
|
177
|
+
|
|
178
|
+
```sh
|
|
179
|
+
KAMAL_BACKUP_ALLOW_RESTORE=true
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Database restores use restore-specific environment by default. They do not restore to `DATABASE_URL`.
|
|
183
|
+
|
|
184
|
+
PostgreSQL restore:
|
|
185
|
+
|
|
186
|
+
```sh
|
|
187
|
+
bin/kamal accessory exec backup \
|
|
188
|
+
--env KAMAL_BACKUP_ALLOW_RESTORE=true \
|
|
189
|
+
--env RESTORE_DATABASE_URL=postgres://app@app-db:5432/app_restore \
|
|
190
|
+
"kamal-backup restore-db latest"
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
MySQL/MariaDB restore:
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
bin/kamal accessory exec backup \
|
|
197
|
+
--env KAMAL_BACKUP_ALLOW_RESTORE=true \
|
|
198
|
+
--env RESTORE_DATABASE_URL=mysql2://app@app-mysql:3306/app_restore \
|
|
199
|
+
"kamal-backup restore-db latest"
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
SQLite restore:
|
|
203
|
+
|
|
204
|
+
```sh
|
|
205
|
+
bin/kamal accessory exec backup \
|
|
206
|
+
--env KAMAL_BACKUP_ALLOW_RESTORE=true \
|
|
207
|
+
--env RESTORE_SQLITE_DATABASE_PATH=/restore/db/restore.sqlite3 \
|
|
208
|
+
"kamal-backup restore-db latest"
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
File restore:
|
|
212
|
+
|
|
213
|
+
```sh
|
|
214
|
+
bin/kamal accessory exec backup \
|
|
215
|
+
--env KAMAL_BACKUP_ALLOW_RESTORE=true \
|
|
216
|
+
"kamal-backup restore-files latest /restore/files"
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Restore targets that look production-like are refused unless:
|
|
220
|
+
|
|
221
|
+
```sh
|
|
222
|
+
KAMAL_BACKUP_ALLOW_PRODUCTION_RESTORE=true
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
File restores to configured backup paths are refused unless:
|
|
226
|
+
|
|
227
|
+
```sh
|
|
228
|
+
KAMAL_BACKUP_ALLOW_IN_PLACE_FILE_RESTORE=true
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Evidence
|
|
232
|
+
|
|
233
|
+
`kamal-backup evidence` prints a redacted JSON summary suitable for operational evidence:
|
|
234
|
+
|
|
235
|
+
- app name
|
|
236
|
+
- current time
|
|
237
|
+
- database adapter
|
|
238
|
+
- redacted restic repository
|
|
239
|
+
- configured file backup paths
|
|
240
|
+
- whether client-side forget/prune is enabled
|
|
241
|
+
- retention policy
|
|
242
|
+
- latest database and file snapshots
|
|
243
|
+
- last tracked `restic check` result
|
|
244
|
+
- image version
|
|
245
|
+
- installed tool versions
|
|
246
|
+
|
|
247
|
+
Secrets, passwords, access keys, and database URL credentials are redacted.
|
|
248
|
+
|
|
249
|
+
Run:
|
|
250
|
+
|
|
251
|
+
```sh
|
|
252
|
+
bin/kamal accessory exec backup "kamal-backup evidence"
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## Local Development
|
|
256
|
+
|
|
257
|
+
Run tests:
|
|
258
|
+
|
|
259
|
+
```sh
|
|
260
|
+
bin/test
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Run docs locally:
|
|
264
|
+
|
|
265
|
+
```sh
|
|
266
|
+
cd docs
|
|
267
|
+
bundle install
|
|
268
|
+
bundle exec jekyll serve --livereload
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Published docs are configured for `https://kamal-backup.dev`.
|
|
272
|
+
|
|
273
|
+
Build the image:
|
|
274
|
+
|
|
275
|
+
```sh
|
|
276
|
+
docker build -t kamal-backup .
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
CI publishes container images to `ghcr.io/crmne/kamal-backup`. Pull requests build the image without pushing; branch, tag, SHA, default-branch `latest`, and default-branch version tags are pushed on non-PR builds. The version tag comes from `lib/kamal_backup/version.rb`, matching the gem version.
|
|
280
|
+
|
|
281
|
+
The CLI is packaged as the `kamal-backup` gem. The Docker image builds and installs that gem, which is why `kamal-backup` is on `PATH` inside the container. On default-branch CI, a new gem version is published to RubyGems and GitHub Packages when it does not already exist. The RubyGems publish step expects the repository secret `RUBYGEMS_AUTH_TOKEN`.
|
|
282
|
+
|
|
283
|
+
For local Ruby use:
|
|
284
|
+
|
|
285
|
+
```sh
|
|
286
|
+
gem build kamal-backup.gemspec
|
|
287
|
+
gem install ./kamal-backup-*.gem
|
|
288
|
+
kamal-backup --help
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
In normal Kamal use, you do not need to install the gem on the app host. Run the command inside the accessory:
|
|
292
|
+
|
|
293
|
+
```sh
|
|
294
|
+
bin/kamal accessory exec backup "kamal-backup evidence"
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Run a local backup against a filesystem restic repository:
|
|
298
|
+
|
|
299
|
+
```sh
|
|
300
|
+
export APP_NAME=local-app
|
|
301
|
+
export DATABASE_ADAPTER=sqlite
|
|
302
|
+
export SQLITE_DATABASE_PATH=/tmp/app.sqlite3
|
|
303
|
+
export BACKUP_PATHS=/tmp/app-files
|
|
304
|
+
export RESTIC_REPOSITORY=/tmp/kamal-backup-restic
|
|
305
|
+
export RESTIC_PASSWORD=local-password
|
|
306
|
+
export RESTIC_INIT_IF_MISSING=true
|
|
307
|
+
|
|
308
|
+
kamal-backup backup
|
|
309
|
+
kamal-backup list
|
|
310
|
+
kamal-backup evidence
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
An example Docker Compose setup for local integration work is in `examples/docker-compose.integration.yml`.
|
|
314
|
+
|
|
315
|
+
## Container Contents
|
|
316
|
+
|
|
317
|
+
The image is based on Debian slim Ruby and includes:
|
|
318
|
+
|
|
319
|
+
- Ruby runtime
|
|
320
|
+
- `pg_dump` and `pg_restore`
|
|
321
|
+
- `mariadb-dump` or `mysqldump`, plus `mariadb` or `mysql`
|
|
322
|
+
- `sqlite3`
|
|
323
|
+
- `restic`
|
|
324
|
+
- CA certificates
|
|
325
|
+
- `tini`
|
|
326
|
+
|
|
327
|
+
## Security Notes
|
|
328
|
+
|
|
329
|
+
- Subprocesses are executed with argument arrays, not shell interpolation.
|
|
330
|
+
- The CLI redacts secrets in errors and evidence output.
|
|
331
|
+
- Database backups use logical dump tools.
|
|
332
|
+
- File data should be mounted read-only in the backup accessory.
|
|
333
|
+
- Restores require explicit environment flags.
|
|
334
|
+
- Object storage credentials should be least-privilege for the backup bucket or prefix.
|
|
335
|
+
|
|
336
|
+
## Non-Goals
|
|
337
|
+
|
|
338
|
+
- Not a hosted backup service.
|
|
339
|
+
- Not a replacement for database point-in-time recovery.
|
|
340
|
+
- Not a physical replication tool.
|
|
341
|
+
- Not a secret manager.
|
|
342
|
+
|
|
343
|
+
## License
|
|
344
|
+
|
|
345
|
+
MIT
|
data/exe/kamal-backup
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "optparse"
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "config"
|
|
5
|
+
require_relative "databases/postgres"
|
|
6
|
+
require_relative "databases/mysql"
|
|
7
|
+
require_relative "databases/sqlite"
|
|
8
|
+
require_relative "evidence"
|
|
9
|
+
require_relative "redactor"
|
|
10
|
+
require_relative "restic"
|
|
11
|
+
require_relative "scheduler"
|
|
12
|
+
require_relative "version"
|
|
13
|
+
|
|
14
|
+
module KamalBackup
|
|
15
|
+
class CLI
|
|
16
|
+
HELP = <<~TEXT
|
|
17
|
+
Usage:
|
|
18
|
+
kamal-backup backup
|
|
19
|
+
kamal-backup restore-db [snapshot-or-latest]
|
|
20
|
+
kamal-backup restore-files [snapshot-or-latest] [target-dir]
|
|
21
|
+
kamal-backup list
|
|
22
|
+
kamal-backup check
|
|
23
|
+
kamal-backup evidence
|
|
24
|
+
kamal-backup schedule
|
|
25
|
+
kamal-backup version
|
|
26
|
+
|
|
27
|
+
Environment is used for configuration. See README.md for Kamal accessory examples.
|
|
28
|
+
TEXT
|
|
29
|
+
|
|
30
|
+
def self.start(argv = ARGV, env: ENV)
|
|
31
|
+
new(env: env).run(argv)
|
|
32
|
+
rescue Error => e
|
|
33
|
+
warn("kamal-backup: #{Redactor.new(env: env).redact_string(e.message)}")
|
|
34
|
+
exit(1)
|
|
35
|
+
rescue Interrupt
|
|
36
|
+
warn("kamal-backup: interrupted")
|
|
37
|
+
exit(130)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(env: ENV)
|
|
41
|
+
@config = Config.new(env: env)
|
|
42
|
+
@redactor = Redactor.new(env: env)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def run(argv)
|
|
46
|
+
argv = argv.dup
|
|
47
|
+
command = argv.shift
|
|
48
|
+
return puts(HELP) if command.nil? || %w[-h --help help].include?(command)
|
|
49
|
+
return puts(VERSION) if %w[-v --version version].include?(command)
|
|
50
|
+
|
|
51
|
+
case command
|
|
52
|
+
when "backup"
|
|
53
|
+
backup
|
|
54
|
+
when "restore-db"
|
|
55
|
+
restore_db(argv[0] || "latest")
|
|
56
|
+
when "restore-files"
|
|
57
|
+
restore_files(argv[0] || "latest", argv[1] || "/restore/files")
|
|
58
|
+
when "list"
|
|
59
|
+
list
|
|
60
|
+
when "check"
|
|
61
|
+
check
|
|
62
|
+
when "evidence"
|
|
63
|
+
evidence
|
|
64
|
+
when "schedule"
|
|
65
|
+
schedule
|
|
66
|
+
else
|
|
67
|
+
raise ConfigurationError, "unknown command: #{command}\n\n#{HELP}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def backup
|
|
72
|
+
@config.validate_for_backup!
|
|
73
|
+
timestamp = Time.now.utc.strftime("%Y%m%dT%H%M%SZ")
|
|
74
|
+
restic.ensure_repository!
|
|
75
|
+
database.backup(restic, timestamp)
|
|
76
|
+
restic.backup_paths(@config.backup_paths, tags: ["type:files", "run:#{timestamp}"])
|
|
77
|
+
restic.forget_after_success! if @config.forget_after_backup?
|
|
78
|
+
restic.check! if @config.check_after_backup?
|
|
79
|
+
true
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def restore_db(snapshot)
|
|
83
|
+
@config.validate_for_restic!
|
|
84
|
+
@config.validate_restore_allowed!
|
|
85
|
+
adapter = database
|
|
86
|
+
resolved_snapshot = resolve_snapshot(snapshot, ["type:database", "adapter:#{adapter.adapter_name}"])
|
|
87
|
+
filename = restic.database_file(resolved_snapshot, adapter.adapter_name)
|
|
88
|
+
raise ConfigurationError, "could not find database backup file in snapshot #{resolved_snapshot}" unless filename
|
|
89
|
+
|
|
90
|
+
adapter.restore(restic, resolved_snapshot, filename)
|
|
91
|
+
true
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def restore_files(snapshot, target)
|
|
95
|
+
@config.validate_for_restic!
|
|
96
|
+
@config.validate_restore_allowed!
|
|
97
|
+
target = @config.validate_file_restore_target!(target)
|
|
98
|
+
resolved_snapshot = resolve_snapshot(snapshot, ["type:files"])
|
|
99
|
+
restic.restore_snapshot(resolved_snapshot, target)
|
|
100
|
+
true
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def list
|
|
104
|
+
@config.validate_for_restic!
|
|
105
|
+
puts restic.snapshots.stdout
|
|
106
|
+
true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def check
|
|
110
|
+
@config.validate_for_restic!
|
|
111
|
+
puts restic.check!.stdout
|
|
112
|
+
true
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def evidence
|
|
116
|
+
@config.validate_for_restic!
|
|
117
|
+
puts Evidence.new(@config, restic: restic, redactor: @redactor).to_json
|
|
118
|
+
true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def schedule
|
|
122
|
+
@config.validate_for_backup!
|
|
123
|
+
Scheduler.new(@config) { backup }.run
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def restic
|
|
129
|
+
@restic ||= Restic.new(@config, redactor: @redactor)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def database
|
|
133
|
+
@database ||= begin
|
|
134
|
+
case @config.database_adapter
|
|
135
|
+
when "postgres"
|
|
136
|
+
Databases::Postgres.new(@config, redactor: @redactor)
|
|
137
|
+
when "mysql"
|
|
138
|
+
Databases::Mysql.new(@config, redactor: @redactor)
|
|
139
|
+
when "sqlite"
|
|
140
|
+
Databases::Sqlite.new(@config, redactor: @redactor)
|
|
141
|
+
else
|
|
142
|
+
raise ConfigurationError, "unsupported DATABASE_ADAPTER: #{@config.database_adapter.inspect}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def resolve_snapshot(argument, tags)
|
|
148
|
+
return argument unless argument == "latest"
|
|
149
|
+
|
|
150
|
+
snapshot = restic.latest_snapshot(tags: tags)
|
|
151
|
+
raise ConfigurationError, "no restic snapshot found for #{tags.join(", ")}" unless snapshot
|
|
152
|
+
|
|
153
|
+
snapshot["short_id"] || snapshot["id"]
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require_relative "errors"
|
|
3
|
+
|
|
4
|
+
module KamalBackup
|
|
5
|
+
class CommandSpec
|
|
6
|
+
attr_reader :argv, :env
|
|
7
|
+
|
|
8
|
+
def initialize(argv:, env: {})
|
|
9
|
+
@argv = Array(argv).compact.map(&:to_s)
|
|
10
|
+
@env = env.each_with_object({}) do |(key, value), result|
|
|
11
|
+
next if value.nil? || value.to_s.empty?
|
|
12
|
+
|
|
13
|
+
result[key.to_s] = value.to_s
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
raise ArgumentError, "command argv cannot be empty" if @argv.empty?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def display(redactor)
|
|
20
|
+
env_prefix = env.keys.sort.map { |key| "#{key}=#{redactor.redact_value(key, env[key])}" }
|
|
21
|
+
redactor.redact_string((env_prefix + argv).join(" "))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
CommandResult = Struct.new(:stdout, :stderr, :status, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
class Command
|
|
28
|
+
def self.capture(spec, input: nil, redactor:)
|
|
29
|
+
stdout, stderr, status = Open3.capture3(spec.env, *spec.argv, stdin_data: input)
|
|
30
|
+
result = CommandResult.new(stdout: stdout, stderr: stderr, status: status.exitstatus)
|
|
31
|
+
return result if status.success?
|
|
32
|
+
|
|
33
|
+
raise CommandError.new(
|
|
34
|
+
"command failed (#{status.exitstatus}): #{spec.display(redactor)}\n#{redactor.redact_string(stderr)}",
|
|
35
|
+
command: spec,
|
|
36
|
+
status: status.exitstatus,
|
|
37
|
+
stdout: redactor.redact_string(stdout),
|
|
38
|
+
stderr: redactor.redact_string(stderr)
|
|
39
|
+
)
|
|
40
|
+
rescue Errno::ENOENT => e
|
|
41
|
+
raise CommandError.new(
|
|
42
|
+
"command not found: #{spec.argv.first}",
|
|
43
|
+
command: spec,
|
|
44
|
+
status: 127,
|
|
45
|
+
stderr: e.message
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|