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 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,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+
5
+ require "kamal_backup"
6
+
7
+ KamalBackup::CLI.start
@@ -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