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.
@@ -0,0 +1,80 @@
1
+ require "json"
2
+ require "time"
3
+ require_relative "command"
4
+ require_relative "version"
5
+
6
+ module KamalBackup
7
+ class Evidence
8
+ def initialize(config, restic:, redactor:)
9
+ @config = config
10
+ @restic = restic
11
+ @redactor = redactor
12
+ end
13
+
14
+ def to_h
15
+ {
16
+ app_name: @config.app_name,
17
+ generated_at: Time.now.utc.iso8601,
18
+ database_adapter: @config.database_adapter,
19
+ restic_repository: @redactor.redact_string(@config.restic_repository.to_s),
20
+ backup_paths: @config.backup_paths,
21
+ forget_after_backup: @config.forget_after_backup?,
22
+ retention: @config.retention,
23
+ latest_database_backup: latest_snapshot_summary(["type:database"]),
24
+ latest_file_backup: latest_snapshot_summary(["type:files"]),
25
+ last_restic_check: last_check,
26
+ image_version: ENV.fetch("KAMAL_BACKUP_IMAGE_VERSION", VERSION),
27
+ tool_versions: tool_versions
28
+ }
29
+ end
30
+
31
+ def to_json(*args)
32
+ JSON.pretty_generate(to_h, *args)
33
+ end
34
+
35
+ private
36
+
37
+ def latest_snapshot_summary(tags)
38
+ snapshot = @restic.latest_snapshot(tags: tags)
39
+ return nil unless snapshot
40
+
41
+ {
42
+ id: snapshot["short_id"] || snapshot["id"],
43
+ time: snapshot["time"],
44
+ tags: snapshot["tags"]
45
+ }
46
+ rescue Error => e
47
+ { error: @redactor.redact_string(e.message) }
48
+ end
49
+
50
+ def last_check
51
+ return nil unless File.file?(@config.last_check_path)
52
+
53
+ JSON.parse(File.read(@config.last_check_path))
54
+ rescue JSON::ParserError, SystemCallError => e
55
+ { error: @redactor.redact_string(e.message) }
56
+ end
57
+
58
+ def tool_versions
59
+ {
60
+ pg_dump: version_for(["pg_dump", "--version"]),
61
+ pg_restore: version_for(["pg_restore", "--version"]),
62
+ mysql_dump: version_for(["mariadb-dump", "--version"], ["mysqldump", "--version"]),
63
+ mysql_client: version_for(["mariadb", "--version"], ["mysql", "--version"]),
64
+ sqlite3: version_for(["sqlite3", "--version"]),
65
+ restic: version_for(["restic", "version"])
66
+ }
67
+ end
68
+
69
+ def version_for(*commands)
70
+ commands.each do |argv|
71
+ result = Command.capture(CommandSpec.new(argv: argv), redactor: @redactor)
72
+ output = result.stdout.empty? ? result.stderr : result.stdout
73
+ return @redactor.redact_string(output.strip)
74
+ rescue CommandError
75
+ next
76
+ end
77
+ "unavailable"
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,53 @@
1
+ require "uri"
2
+
3
+ module KamalBackup
4
+ class Redactor
5
+ SECRET_KEY_PATTERN = /(pass|password|secret|token|key|credential|authorization)/i
6
+ REDACTED = "[REDACTED]"
7
+
8
+ def initialize(secret_values: [], env: ENV)
9
+ @secret_values = Array(secret_values).compact.map(&:to_s).reject { |value| value.empty? || value.length < 4 }
10
+ @env = env
11
+ end
12
+
13
+ def redact_hash(hash)
14
+ hash.each_with_object({}) do |(key, value), result|
15
+ result[key] = redact_value(key, value)
16
+ end
17
+ end
18
+
19
+ def redact_value(key, value)
20
+ return nil if value.nil?
21
+ return REDACTED if key.to_s.match?(SECRET_KEY_PATTERN)
22
+
23
+ redact_string(value.to_s)
24
+ end
25
+
26
+ def redact_string(value)
27
+ redacted = redact_url_credentials(value.to_s)
28
+ known_secret_values.each do |secret|
29
+ redacted = redacted.gsub(secret, REDACTED)
30
+ end
31
+ redacted
32
+ end
33
+
34
+ private
35
+
36
+ def known_secret_values
37
+ @known_secret_values ||= begin
38
+ env_secrets = @env.each_with_object([]) do |(key, value), values|
39
+ values << value.to_s if key.to_s.match?(SECRET_KEY_PATTERN)
40
+ end
41
+ (@secret_values + env_secrets).compact.uniq.reject { |value| value.empty? || value.length < 4 }
42
+ end
43
+ end
44
+
45
+ def redact_url_credentials(value)
46
+ value.gsub(%r{(://)([^/\s:@]+)(?::([^/\s@]*))?@}) do
47
+ "#{$1}#{REDACTED}@"
48
+ end.gsub(/([?&](?:password|token|secret|key|access_key_id|secret_access_key)=)[^&\s]+/i) do
49
+ "#{$1}#{REDACTED}"
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,239 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "open3"
4
+ require "time"
5
+ require_relative "command"
6
+
7
+ module KamalBackup
8
+ class Restic
9
+ attr_reader :config, :redactor
10
+
11
+ def initialize(config, redactor:)
12
+ @config = config
13
+ @redactor = redactor
14
+ end
15
+
16
+ def ensure_repository!
17
+ run(%w[snapshots --json])
18
+ rescue CommandError => e
19
+ raise e unless config.restic_init_if_missing?
20
+
21
+ log("restic repository not ready, running restic init")
22
+ run(%w[init])
23
+ end
24
+
25
+ def backup_command_output(command, filename:, tags:)
26
+ restic_command = CommandSpec.new(argv: ["restic", "backup", "--stdin", "--stdin-filename", filename] + tag_args(common_tags + tags))
27
+ log("backing up stream as #{filename}")
28
+ pipe_commands(command, restic_command, producer_label: "dump", consumer_label: "restic backup")
29
+ end
30
+
31
+ def backup_file_content(path, filename:, tags:)
32
+ command = CommandSpec.new(argv: ["restic", "backup", "--stdin", "--stdin-filename", filename] + tag_args(common_tags + tags))
33
+ log("backing up file content as #{filename}")
34
+
35
+ File.open(path, "rb") do |file|
36
+ Open3.popen3(command.env, *command.argv) do |stdin, stdout, stderr, wait_thread|
37
+ stdout_reader = Thread.new { stdout.read }
38
+ stderr_reader = Thread.new { stderr.read }
39
+ IO.copy_stream(file, stdin)
40
+ stdin.close
41
+ out = stdout_reader.value
42
+ err = stderr_reader.value
43
+ status = wait_thread.value
44
+ raise_command_error(command, status, out, err) unless status.success?
45
+
46
+ CommandResult.new(stdout: out, stderr: err, status: status.exitstatus)
47
+ end
48
+ end
49
+ rescue Errno::ENOENT => e
50
+ raise CommandError.new("command not found: #{command.argv.first}", command: command, status: 127, stderr: e.message)
51
+ end
52
+
53
+ def backup_paths(paths, tags:)
54
+ paths = Array(paths).compact.map(&:to_s).reject(&:empty?)
55
+ return if paths.empty?
56
+
57
+ path_tags = paths.map { |path| "path:#{config.backup_path_label(path)}" }
58
+ log("backing up #{paths.size} file path(s): #{paths.join(", ")}")
59
+ run(["backup"] + paths + tag_args(common_tags + tags + path_tags))
60
+ end
61
+
62
+ def backup_path(path, tags:)
63
+ backup_paths([path], tags: tags)
64
+ end
65
+
66
+ def forget_after_success!
67
+ args = ["forget", "--prune"] + config.retention_args + tag_args(common_tags)
68
+ log("running restic forget/prune with retention policy")
69
+ run(args)
70
+ end
71
+
72
+ def check!
73
+ args = %w[check]
74
+ args.concat(["--read-data-subset", config.check_read_data_subset]) if config.check_read_data_subset
75
+ started_at = Time.now.utc
76
+ result = run(args)
77
+ write_last_check(status: "ok", started_at: started_at, finished_at: Time.now.utc, output: result.stdout)
78
+ result
79
+ rescue CommandError => e
80
+ write_last_check(status: "failed", started_at: started_at || Time.now.utc, finished_at: Time.now.utc, error: e.message)
81
+ raise
82
+ end
83
+
84
+ def snapshots(tags: common_tags)
85
+ run(["snapshots"] + tag_args(tags))
86
+ end
87
+
88
+ def snapshots_json(tags: common_tags)
89
+ output = run(["snapshots", "--json"] + tag_args(tags)).stdout
90
+ snapshots = JSON.parse(output)
91
+ required_tags = tags.compact
92
+ snapshots.select do |snapshot|
93
+ snapshot_tags = Array(snapshot["tags"])
94
+ required_tags.all? { |tag| snapshot_tags.include?(tag) }
95
+ end
96
+ end
97
+
98
+ def latest_snapshot(tags:)
99
+ snapshots = snapshots_json(tags: common_tags + tags)
100
+ snapshots.max_by { |snapshot| Time.parse(snapshot.fetch("time")) }
101
+ rescue JSON::ParserError
102
+ nil
103
+ end
104
+
105
+ def ls_json(snapshot)
106
+ output = run(["ls", "--json", snapshot]).stdout
107
+ output.lines.filter_map do |line|
108
+ JSON.parse(line)
109
+ rescue JSON::ParserError
110
+ nil
111
+ end
112
+ end
113
+
114
+ def database_file(snapshot, adapter)
115
+ legacy_prefix = "databases/#{config.app_name}/#{adapter}/"
116
+ flat_prefix = "databases-#{config.app_name.gsub(/[^A-Za-z0-9_.-]+/, "-")}-#{adapter}-"
117
+ ls_json(snapshot).find do |entry|
118
+ next false unless entry["type"] == "file"
119
+
120
+ normalized = entry["path"].to_s.sub(%r{\A/+}, "")
121
+ normalized.start_with?(legacy_prefix) || File.basename(normalized).start_with?(flat_prefix)
122
+ end&.fetch("path")
123
+ end
124
+
125
+ def dump_file_to_command(snapshot, filename, command)
126
+ restic_command = CommandSpec.new(argv: ["restic", "dump", snapshot, filename])
127
+ pipe_commands(restic_command, command, producer_label: "restic dump", consumer_label: command.argv.first)
128
+ end
129
+
130
+ def dump_file_to_path(snapshot, filename, target_path)
131
+ command = CommandSpec.new(argv: ["restic", "dump", snapshot, filename])
132
+ target_path = File.expand_path(target_path)
133
+ FileUtils.mkdir_p(File.dirname(target_path))
134
+ temp_path = "#{target_path}.kamal-backup-#{$$}.tmp"
135
+
136
+ Open3.popen3(command.env, *command.argv) do |stdin, stdout, stderr, wait_thread|
137
+ stdin.close
138
+ stderr_reader = Thread.new { stderr.read }
139
+ File.open(temp_path, "wb") { |file| IO.copy_stream(stdout, file) }
140
+ err = stderr_reader.value
141
+ status = wait_thread.value
142
+ raise_command_error(command, status, "", err) unless status.success?
143
+ end
144
+ File.rename(temp_path, target_path)
145
+ target_path
146
+ rescue Errno::ENOENT => e
147
+ FileUtils.rm_f(temp_path) if temp_path
148
+ raise CommandError.new("command not found: #{command.argv.first}", command: command, status: 127, stderr: e.message)
149
+ rescue StandardError
150
+ FileUtils.rm_f(temp_path) if temp_path
151
+ raise
152
+ end
153
+
154
+ def restore_snapshot(snapshot, target)
155
+ log("restoring file snapshot #{snapshot} to #{target}")
156
+ run(["restore", snapshot, "--target", target])
157
+ end
158
+
159
+ def run(args)
160
+ Command.capture(CommandSpec.new(argv: ["restic"] + args), redactor: redactor)
161
+ end
162
+
163
+ def common_tags
164
+ ["kamal-backup", "app:#{config.app_name}"]
165
+ end
166
+
167
+ private
168
+
169
+ def tag_args(tags)
170
+ tags.compact.each_with_object([]) { |tag, args| args.concat(["--tag", tag]) }
171
+ end
172
+
173
+ def pipe_commands(producer, consumer, producer_label:, consumer_label:)
174
+ Open3.popen3(producer.env, *producer.argv) do |producer_stdin, producer_stdout, producer_stderr, producer_wait|
175
+ producer_stdin.close
176
+
177
+ Open3.popen3(consumer.env, *consumer.argv) do |consumer_stdin, consumer_stdout, consumer_stderr, consumer_wait|
178
+ producer_err_reader = Thread.new { producer_stderr.read }
179
+ consumer_out_reader = Thread.new { consumer_stdout.read }
180
+ consumer_err_reader = Thread.new { consumer_stderr.read }
181
+
182
+ copy_error = nil
183
+ copy_thread = Thread.new do
184
+ IO.copy_stream(producer_stdout, consumer_stdin)
185
+ rescue StandardError => e
186
+ copy_error = e
187
+ ensure
188
+ consumer_stdin.close unless consumer_stdin.closed?
189
+ end
190
+
191
+ copy_thread.join
192
+ producer_status = producer_wait.value
193
+ consumer_status = consumer_wait.value
194
+
195
+ producer_err = producer_err_reader.value
196
+ consumer_out = consumer_out_reader.value
197
+ consumer_err = consumer_err_reader.value
198
+
199
+ if copy_error
200
+ raise CommandError.new(
201
+ "failed to pipe #{producer_label} to #{consumer_label}: #{copy_error.message}",
202
+ command: consumer,
203
+ stderr: copy_error.message
204
+ )
205
+ end
206
+
207
+ raise_command_error(producer, producer_status, "", producer_err) unless producer_status.success?
208
+ raise_command_error(consumer, consumer_status, consumer_out, consumer_err) unless consumer_status.success?
209
+
210
+ CommandResult.new(stdout: consumer_out, stderr: consumer_err, status: consumer_status.exitstatus)
211
+ end
212
+ end
213
+ rescue Errno::ENOENT => e
214
+ command = e.message.include?(producer.argv.first) ? producer : consumer
215
+ raise CommandError.new("command not found: #{command.argv.first}", command: command, status: 127, stderr: e.message)
216
+ end
217
+
218
+ def raise_command_error(command, status, stdout, stderr)
219
+ raise CommandError.new(
220
+ "command failed (#{status.exitstatus}): #{command.display(redactor)}\n#{redactor.redact_string(stderr)}",
221
+ command: command,
222
+ status: status.exitstatus,
223
+ stdout: redactor.redact_string(stdout),
224
+ stderr: redactor.redact_string(stderr)
225
+ )
226
+ end
227
+
228
+ def write_last_check(payload)
229
+ FileUtils.mkdir_p(config.state_dir)
230
+ File.write(config.last_check_path, JSON.pretty_generate(payload.transform_values { |value| value.respond_to?(:iso8601) ? value.iso8601 : redactor.redact_string(value.to_s) }))
231
+ rescue SystemCallError
232
+ nil
233
+ end
234
+
235
+ def log(message)
236
+ $stdout.puts("[kamal-backup] #{redactor.redact_string(message)}")
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,50 @@
1
+ require "time"
2
+
3
+ module KamalBackup
4
+ class Scheduler
5
+ def initialize(config, &backup_block)
6
+ @config = config
7
+ @backup_block = backup_block
8
+ @stop = false
9
+ end
10
+
11
+ def run
12
+ install_signal_handlers
13
+ sleep_interruptibly(@config.backup_start_delay_seconds)
14
+
15
+ until @stop
16
+ started_at = Time.now.utc
17
+ log("backup started at #{started_at.iso8601}")
18
+ begin
19
+ @backup_block.call
20
+ log("backup completed at #{Time.now.utc.iso8601}")
21
+ rescue StandardError => e
22
+ warn("[kamal-backup] backup failed at #{Time.now.utc.iso8601}: #{e.class}: #{e.message}")
23
+ end
24
+
25
+ sleep_interruptibly(@config.backup_schedule_seconds)
26
+ end
27
+
28
+ log("scheduler stopped at #{Time.now.utc.iso8601}")
29
+ end
30
+
31
+ private
32
+
33
+ def install_signal_handlers
34
+ %w[TERM INT].each do |signal|
35
+ Signal.trap(signal) { @stop = true }
36
+ rescue ArgumentError
37
+ nil
38
+ end
39
+ end
40
+
41
+ def sleep_interruptibly(seconds)
42
+ deadline = Time.now + seconds
43
+ sleep([deadline - Time.now, 1].min) while !@stop && Time.now < deadline
44
+ end
45
+
46
+ def log(message)
47
+ $stdout.puts("[kamal-backup] #{message}")
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,3 @@
1
+ module KamalBackup
2
+ VERSION = "0.1.0.pre.1"
3
+ end
@@ -0,0 +1,13 @@
1
+ require_relative "kamal_backup/version"
2
+ require_relative "kamal_backup/errors"
3
+ require_relative "kamal_backup/command"
4
+ require_relative "kamal_backup/redactor"
5
+ require_relative "kamal_backup/config"
6
+ require_relative "kamal_backup/restic"
7
+ require_relative "kamal_backup/evidence"
8
+ require_relative "kamal_backup/scheduler"
9
+ require_relative "kamal_backup/databases/base"
10
+ require_relative "kamal_backup/databases/postgres"
11
+ require_relative "kamal_backup/databases/mysql"
12
+ require_relative "kamal_backup/databases/sqlite"
13
+ require_relative "kamal_backup/cli"
metadata ADDED
@@ -0,0 +1,68 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kamal-backup
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0.pre.1
5
+ platform: ruby
6
+ authors:
7
+ - Carmine Paolino
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-04-22 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: A small CLI and Docker image for encrypted, verifiable Kamal accessory
14
+ backups using restic.
15
+ email:
16
+ - carmine@paolino.me
17
+ executables:
18
+ - kamal-backup
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - LICENSE
23
+ - README.md
24
+ - exe/kamal-backup
25
+ - lib/kamal_backup.rb
26
+ - lib/kamal_backup/cli.rb
27
+ - lib/kamal_backup/command.rb
28
+ - lib/kamal_backup/config.rb
29
+ - lib/kamal_backup/databases/base.rb
30
+ - lib/kamal_backup/databases/mysql.rb
31
+ - lib/kamal_backup/databases/postgres.rb
32
+ - lib/kamal_backup/databases/sqlite.rb
33
+ - lib/kamal_backup/errors.rb
34
+ - lib/kamal_backup/evidence.rb
35
+ - lib/kamal_backup/redactor.rb
36
+ - lib/kamal_backup/restic.rb
37
+ - lib/kamal_backup/scheduler.rb
38
+ - lib/kamal_backup/version.rb
39
+ homepage: https://kamal-backup.dev
40
+ licenses:
41
+ - MIT
42
+ metadata:
43
+ homepage_uri: https://kamal-backup.dev
44
+ source_code_uri: https://github.com/crmne/kamal-backup
45
+ changelog_uri: https://github.com/crmne/kamal-backup/releases
46
+ bug_tracker_uri: https://github.com/crmne/kamal-backup/issues
47
+ funding_uri: https://github.com/sponsors/crmne
48
+ rubygems_mfa_required: 'true'
49
+ post_install_message:
50
+ rdoc_options: []
51
+ require_paths:
52
+ - lib
53
+ required_ruby_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: '3.1'
58
+ required_rubygems_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ requirements: []
64
+ rubygems_version: 3.5.22
65
+ signing_key:
66
+ specification_version: 4
67
+ summary: Kamal-first restic backups for databases and mounted application files.
68
+ test_files: []