kamal-backup 1.0.0 → 1.1.0
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 +3 -2
- data/lib/kamal_backup/app.rb +154 -1
- data/lib/kamal_backup/cli/helpers.rb +98 -0
- data/lib/kamal_backup/cli.rb +34 -0
- data/lib/kamal_backup/databases/sqlite.rb +1 -1
- data/lib/kamal_backup/kamal_bridge.rb +418 -7
- data/lib/kamal_backup/private_tempfile.rb +82 -0
- data/lib/kamal_backup/redactor.rb +6 -1
- data/lib/kamal_backup/restic.rb +44 -13
- data/lib/kamal_backup/version.rb +1 -1
- data/lib/kamal_backup.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a381b9f627864fc652f6d23b5109d5a670d0a4808f075d0d129fa2292da0eac3
|
|
4
|
+
data.tar.gz: aedbd53cb4486c1b2cc440e7825129d4823c948e1d8fc387b9742f3179363a80
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ca9c294db8d9d5445ca395f80d23d7cfa2d9eaaf26ab28e628f5ee264ef268758e7c03d504eea2cb98e54c72e3ba0f3cd999ececfe3c0475bbd434327f8244b5
|
|
7
|
+
data.tar.gz: 05b9046e125145ce936ab33812046aff5a69cc7286bafb355c12d323f91615b948689e6db11ec365e41457addceab11aeff1aaf38b99898793d1e743a6900d44
|
data/README.md
CHANGED
|
@@ -114,6 +114,7 @@ Run the first backup, check the repository, and print evidence. From an app chec
|
|
|
114
114
|
```sh
|
|
115
115
|
bundle exec kamal-backup backup
|
|
116
116
|
bundle exec kamal-backup list
|
|
117
|
+
bundle exec kamal-backup dump latest -o tmp/app.pgdump
|
|
117
118
|
bundle exec kamal-backup check
|
|
118
119
|
bundle exec kamal-backup unlock
|
|
119
120
|
bundle exec kamal-backup evidence
|
|
@@ -144,13 +145,13 @@ Start here:
|
|
|
144
145
|
|
|
145
146
|
## Releasing
|
|
146
147
|
|
|
147
|
-
|
|
148
|
+
Commit hand-written release notes as `packaging/release-notes/vX.Y.Z.md`, then run the release helper from a clean `master` checkout:
|
|
148
149
|
|
|
149
150
|
```sh
|
|
150
151
|
bin/release 1.0.1
|
|
151
152
|
```
|
|
152
153
|
|
|
153
|
-
It updates `lib/kamal_backup/version.rb`, syncs `Gemfile.lock`, commits the release, and pushes `master`. CI runs the test suite and docs build, publishes the RubyGem and Docker image tags, then creates the version tag, GitHub release, and docs deployment from the release commit.
|
|
154
|
+
It updates `lib/kamal_backup/version.rb`, syncs `Gemfile.lock`, commits the release, and pushes `master`. CI runs the test suite and docs build, publishes the RubyGem and Docker image tags, then creates the version tag, GitHub release, and docs deployment from the release commit. The GitHub release uses the committed notes, and CI stops before publishing anything when they are missing.
|
|
154
155
|
|
|
155
156
|
Add `--no-push` to prepare the release commit locally without publishing.
|
|
156
157
|
|
data/lib/kamal_backup/app.rb
CHANGED
|
@@ -100,6 +100,84 @@ module KamalBackup
|
|
|
100
100
|
end
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
+
def locate_database_dump(snapshot: 'latest', database_name: nil, validate_credentials: true)
|
|
104
|
+
if validate_credentials
|
|
105
|
+
config.validate_restic
|
|
106
|
+
else
|
|
107
|
+
config.required_app_name
|
|
108
|
+
unless config.restic_repository || config.restic_repository_file
|
|
109
|
+
raise ConfigurationError,
|
|
110
|
+
'RESTIC_REPOSITORY or RESTIC_REPOSITORY_FILE is required to dump from the backup accessory'
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Dump reads a stored snapshot. It needs the selected database's name and
|
|
115
|
+
# adapter, not live connection secrets or the current SQLite file.
|
|
116
|
+
raise ConfigurationError, 'databases must contain at least one database' if databases.empty?
|
|
117
|
+
|
|
118
|
+
adapter = select_database(database_name)
|
|
119
|
+
resolved_snapshot = resolve_snapshot(snapshot, tags: database_snapshot_tags(adapter))
|
|
120
|
+
database = database_config_name(adapter)
|
|
121
|
+
filename = restic.database_file(
|
|
122
|
+
resolved_snapshot,
|
|
123
|
+
adapter.adapter_name,
|
|
124
|
+
database_name: database
|
|
125
|
+
)
|
|
126
|
+
raise ConfigurationError, "could not find database backup file in snapshot #{resolved_snapshot}" unless filename
|
|
127
|
+
|
|
128
|
+
{
|
|
129
|
+
snapshot: resolved_snapshot,
|
|
130
|
+
database: database,
|
|
131
|
+
adapter: adapter.adapter_name,
|
|
132
|
+
filename: filename,
|
|
133
|
+
dump_extension: adapter.dump_extension
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def dump_database(snapshot: 'latest', database_name: nil, output_path: nil, io: nil, overwrite: false)
|
|
138
|
+
located = locate_database_dump(snapshot: snapshot, database_name: database_name)
|
|
139
|
+
|
|
140
|
+
if output_path
|
|
141
|
+
expanded = require_dump_output_path!(output_path)
|
|
142
|
+
warn_dump_extension!(expanded, located.fetch(:dump_extension))
|
|
143
|
+
restic.write_dump_to_path(
|
|
144
|
+
located.fetch(:snapshot),
|
|
145
|
+
located.fetch(:filename),
|
|
146
|
+
expanded,
|
|
147
|
+
overwrite: overwrite
|
|
148
|
+
)
|
|
149
|
+
located.merge(output: expanded)
|
|
150
|
+
elsif io
|
|
151
|
+
restic.pipe_dump_to_io(located.fetch(:snapshot), located.fetch(:filename), io)
|
|
152
|
+
located.merge(output: 'io')
|
|
153
|
+
else
|
|
154
|
+
raise ConfigurationError, 'output path is required; pass -o PATH'
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def require_dump_output_path!(output_path)
|
|
159
|
+
path = output_path.to_s.strip
|
|
160
|
+
raise ConfigurationError, 'output path is required; pass -o PATH' if path.empty?
|
|
161
|
+
raise ConfigurationError, 'output path must be a file, not a directory' if path.end_with?('/', '\\')
|
|
162
|
+
|
|
163
|
+
expanded = File.expand_path(path)
|
|
164
|
+
raise ConfigurationError, "output path must be a file, not a directory: #{expanded}" if File.directory?(expanded)
|
|
165
|
+
|
|
166
|
+
parent = File.dirname(expanded)
|
|
167
|
+
raise ConfigurationError, "output path directory does not exist: #{parent}" unless File.directory?(parent)
|
|
168
|
+
|
|
169
|
+
expanded
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def warn_dump_extension!(output_path, expected_extension)
|
|
173
|
+
expected = ".#{expected_extension}"
|
|
174
|
+
actual = File.extname(output_path.to_s)
|
|
175
|
+
return if actual.casecmp?(expected)
|
|
176
|
+
|
|
177
|
+
actual_label = actual.empty? ? 'no extension' : actual.inspect
|
|
178
|
+
warn("warning: output path has #{actual_label}; expected #{expected.inspect} for this database dump")
|
|
179
|
+
end
|
|
180
|
+
|
|
103
181
|
def snapshots
|
|
104
182
|
config.validate_restic
|
|
105
183
|
restic.snapshots.stdout
|
|
@@ -524,6 +602,22 @@ module KamalBackup
|
|
|
524
602
|
end
|
|
525
603
|
end
|
|
526
604
|
|
|
605
|
+
def select_database(database_name)
|
|
606
|
+
names = databases.map { |adapter| database_config_name(adapter) }
|
|
607
|
+
|
|
608
|
+
unless database_name.to_s.strip.empty?
|
|
609
|
+
return databases.find { |adapter| database_config_name(adapter) == database_name } ||
|
|
610
|
+
raise(
|
|
611
|
+
ConfigurationError,
|
|
612
|
+
"database #{database_name.inspect} is not configured; configured names: #{names.join(', ')}"
|
|
613
|
+
)
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
return databases.first if databases.one?
|
|
617
|
+
|
|
618
|
+
raise ConfigurationError, "multiple databases configured (#{names.join(', ')}); pass --database NAME"
|
|
619
|
+
end
|
|
620
|
+
|
|
527
621
|
def resolve_snapshot(argument, tags:)
|
|
528
622
|
if argument == 'latest'
|
|
529
623
|
snapshot = restic.latest_snapshot(tags: tags)
|
|
@@ -532,8 +626,67 @@ module KamalBackup
|
|
|
532
626
|
|
|
533
627
|
snapshot['short_id'] || snapshot['id']
|
|
534
628
|
else
|
|
535
|
-
argument
|
|
629
|
+
resolve_explicit_snapshot(argument, tags: tags)
|
|
536
630
|
end
|
|
537
631
|
end
|
|
632
|
+
|
|
633
|
+
# Each backup run writes one snapshot per database and then one file
|
|
634
|
+
# snapshot, each with its own ID. An explicit ID names one of them, so the
|
|
635
|
+
# other types come from the same run.
|
|
636
|
+
def resolve_explicit_snapshot(argument, tags:)
|
|
637
|
+
anchor = find_snapshot(argument)
|
|
638
|
+
snapshot = backup_run_for(anchor).find { |candidate| snapshot_tagged?(candidate, tags) }
|
|
639
|
+
|
|
640
|
+
unless snapshot
|
|
641
|
+
raise ConfigurationError,
|
|
642
|
+
"the backup containing snapshot #{argument} has no snapshot for #{tags.join(', ')}"
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
snapshot['short_id'] || snapshot['id']
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
def find_snapshot(argument)
|
|
649
|
+
matches = backup_snapshots.select do |snapshot|
|
|
650
|
+
snapshot['short_id'] == argument || snapshot['id'].to_s.start_with?(argument)
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
raise ConfigurationError, "no restic snapshot found for #{argument}" if matches.empty?
|
|
654
|
+
raise ConfigurationError, "snapshot ID #{argument} is ambiguous; use a longer ID" if matches.size > 1
|
|
655
|
+
|
|
656
|
+
matches.first
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
# Snapshots from one host, in time order, split into backup runs. A run
|
|
660
|
+
# ends after its file snapshot (always written last) or when a database
|
|
661
|
+
# snapshot group repeats, so a run that failed partway stays on its own.
|
|
662
|
+
def backup_run_for(anchor)
|
|
663
|
+
host_snapshots = backup_snapshots.select { |snapshot| snapshot['hostname'] == anchor['hostname'] }
|
|
664
|
+
runs = host_snapshots.sort_by { |snapshot| Time.parse(snapshot.fetch('time')) }.each_with_object([]) do |snapshot, grouped|
|
|
665
|
+
current = grouped.last
|
|
666
|
+
if current.nil? || current.any? { |other| run_boundary?(other, snapshot) }
|
|
667
|
+
grouped << [snapshot]
|
|
668
|
+
else
|
|
669
|
+
current << snapshot
|
|
670
|
+
end
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
runs.find { |run| run.include?(anchor) }
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def run_boundary?(earlier, later)
|
|
677
|
+
snapshot_tagged?(earlier, ['type:files']) || snapshot_group(earlier) == snapshot_group(later)
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def snapshot_group(snapshot)
|
|
681
|
+
Array(snapshot['tags']).select { |tag| tag.start_with?('type:', 'database:', 'adapter:') }.sort
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
def snapshot_tagged?(snapshot, tags)
|
|
685
|
+
(tags - Array(snapshot['tags'])).empty?
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
def backup_snapshots
|
|
689
|
+
@backup_snapshots ||= restic.snapshots_json
|
|
690
|
+
end
|
|
538
691
|
end
|
|
539
692
|
end
|
|
@@ -5,6 +5,7 @@ require 'json'
|
|
|
5
5
|
require 'shellwords'
|
|
6
6
|
require 'thor'
|
|
7
7
|
require_relative '../app'
|
|
8
|
+
require_relative '../private_tempfile'
|
|
8
9
|
require_relative '../command_output'
|
|
9
10
|
require_relative '../config'
|
|
10
11
|
require_relative '../kamal_bridge'
|
|
@@ -111,6 +112,103 @@ module KamalBackup
|
|
|
111
112
|
result
|
|
112
113
|
end
|
|
113
114
|
|
|
115
|
+
def require_dump_output_path!(output_path)
|
|
116
|
+
path = output_path.to_s.strip
|
|
117
|
+
raise ConfigurationError, 'output path is required; pass -o PATH' if path.empty?
|
|
118
|
+
raise ConfigurationError, 'output path must be a file, not a directory' if path.end_with?('/', '\\')
|
|
119
|
+
|
|
120
|
+
expanded = File.expand_path(path)
|
|
121
|
+
raise ConfigurationError, "output path must be a file, not a directory: #{expanded}" if File.directory?(expanded)
|
|
122
|
+
|
|
123
|
+
parent = File.dirname(expanded)
|
|
124
|
+
raise ConfigurationError, "output path directory does not exist: #{parent}" unless File.directory?(parent)
|
|
125
|
+
|
|
126
|
+
expanded
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def warn_dump_extension!(output_path, expected_extension)
|
|
130
|
+
expected = ".#{expected_extension}"
|
|
131
|
+
actual = File.extname(output_path.to_s)
|
|
132
|
+
return if actual.casecmp?(expected)
|
|
133
|
+
|
|
134
|
+
actual_label = actual.empty? ? 'no extension' : actual.inspect
|
|
135
|
+
warn("warning: output path has #{actual_label}; expected #{expected.inspect} for this database dump")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def dump_overwrite?(output_path)
|
|
139
|
+
return true if options[:yes]
|
|
140
|
+
return false unless File.exist?(output_path)
|
|
141
|
+
|
|
142
|
+
confirm!("Overwrite #{output_path}? This will replace the existing file.")
|
|
143
|
+
true
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def dump_remote(snapshot, output_path:, overwrite:)
|
|
147
|
+
ensure_remote_version_match!
|
|
148
|
+
config = remote_dump_config
|
|
149
|
+
location = remote_restic_location(config)
|
|
150
|
+
located = remote_dump_app(config, location).locate_database_dump(
|
|
151
|
+
snapshot: snapshot,
|
|
152
|
+
database_name: options[:database],
|
|
153
|
+
validate_credentials: false
|
|
154
|
+
)
|
|
155
|
+
filename = located.fetch(:filename)
|
|
156
|
+
expanded = require_dump_output_path!(output_path)
|
|
157
|
+
warn_dump_extension!(expanded, located.fetch(:dump_extension))
|
|
158
|
+
temp = nil
|
|
159
|
+
|
|
160
|
+
begin
|
|
161
|
+
temp = PrivateTempfile.open(expanded)
|
|
162
|
+
bridge.stream_restic_dump(
|
|
163
|
+
accessory_name: accessory_name,
|
|
164
|
+
snapshot: located.fetch(:snapshot),
|
|
165
|
+
filename: filename,
|
|
166
|
+
io: temp,
|
|
167
|
+
**location
|
|
168
|
+
)
|
|
169
|
+
PrivateTempfile.publish(temp, expanded, overwrite: overwrite)
|
|
170
|
+
warn("wrote #{filename.sub(%r{\A/+}, '')} from snapshot #{located.fetch(:snapshot)} to #{expanded}")
|
|
171
|
+
ensure
|
|
172
|
+
PrivateTempfile.discard(temp)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def remote_dump_config
|
|
177
|
+
Config.new(
|
|
178
|
+
env: bridge.accessory_environment(accessory_name: accessory_name),
|
|
179
|
+
config_paths: [Config::SHARED_CONFIG_PATH],
|
|
180
|
+
load_project_defaults: false
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def remote_restic_location(config)
|
|
185
|
+
if (repository = config.restic_repository)
|
|
186
|
+
{ repository: repository }
|
|
187
|
+
elsif (repository_file = config.restic_repository_file)
|
|
188
|
+
{ repository_file: repository_file }
|
|
189
|
+
else
|
|
190
|
+
raise ConfigurationError,
|
|
191
|
+
'RESTIC_REPOSITORY or RESTIC_REPOSITORY_FILE is required to dump from the backup accessory'
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def remote_dump_app(config, location)
|
|
196
|
+
runner = lambda do |args, **|
|
|
197
|
+
stdout = bridge.capture_restic_command(
|
|
198
|
+
accessory_name: accessory_name,
|
|
199
|
+
argv: args,
|
|
200
|
+
**location
|
|
201
|
+
)
|
|
202
|
+
CommandResult.new(stdout: stdout, stderr: '', status: 0)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
App.new(
|
|
206
|
+
config: config,
|
|
207
|
+
redactor: redactor,
|
|
208
|
+
restic: Restic.new(config, redactor: redactor, runner: runner)
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
|
|
114
212
|
def ensure_remote_version_match!
|
|
115
213
|
return if remote_version == VERSION
|
|
116
214
|
|
data/lib/kamal_backup/cli.rb
CHANGED
|
@@ -130,6 +130,7 @@ module KamalBackup
|
|
|
130
130
|
|
|
131
131
|
package_name 'kamal-backup'
|
|
132
132
|
map %w[-v --version] => :version
|
|
133
|
+
stop_on_unknown_option! :run_restic
|
|
133
134
|
class_option :config_file, aliases: '-c', type: :string, desc: 'Path to Kamal deploy config file'
|
|
134
135
|
class_option :destination, aliases: '-d', type: :string, desc: 'Kamal destination to use'
|
|
135
136
|
remove_command :tree
|
|
@@ -182,6 +183,39 @@ module KamalBackup
|
|
|
182
183
|
end
|
|
183
184
|
end
|
|
184
185
|
|
|
186
|
+
method_option :output, aliases: '-o', type: :string,
|
|
187
|
+
desc: 'Write the dump to this path (required; parent directory must exist)'
|
|
188
|
+
method_option :database, type: :string,
|
|
189
|
+
desc: 'Name from config/kamal-backup.yml when multiple databases are configured'
|
|
190
|
+
method_option :yes, aliases: '-y', type: :boolean, default: false,
|
|
191
|
+
desc: 'Replace the output file if it exists, including one created while the dump downloads'
|
|
192
|
+
desc 'dump [SNAPSHOT]', 'Download a database dump to a file (not file or Active Storage backups)'
|
|
193
|
+
def dump(snapshot = 'latest')
|
|
194
|
+
output_path = require_dump_output_path!(options[:output])
|
|
195
|
+
overwrite = dump_overwrite?(output_path)
|
|
196
|
+
|
|
197
|
+
if remote_command_mode?
|
|
198
|
+
dump_remote(snapshot, output_path: output_path, overwrite: overwrite)
|
|
199
|
+
else
|
|
200
|
+
result = direct_app.dump_database(
|
|
201
|
+
snapshot: snapshot,
|
|
202
|
+
database_name: options[:database],
|
|
203
|
+
output_path: output_path,
|
|
204
|
+
overwrite: overwrite
|
|
205
|
+
)
|
|
206
|
+
warn("wrote #{result[:filename]} from snapshot #{result[:snapshot]} to #{result[:output]}")
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
desc 'run-restic ARGS...', 'Run restic with the loaded backup configuration', hide: true
|
|
211
|
+
def run_restic(*args)
|
|
212
|
+
raise ConfigurationError, 'restic arguments are required' if args.empty?
|
|
213
|
+
|
|
214
|
+
config = Config.new(env: command_env)
|
|
215
|
+
config.validate_restic
|
|
216
|
+
exec(Restic.environment_for(config), 'restic', *args)
|
|
217
|
+
end
|
|
218
|
+
|
|
185
219
|
desc 'check', 'Run restic check and record the latest result'
|
|
186
220
|
def check
|
|
187
221
|
if remote_command_mode?
|
|
@@ -71,7 +71,7 @@ module KamalBackup
|
|
|
71
71
|
def restore_database(restic, snapshot, filename, target:)
|
|
72
72
|
Tempfile.create(['kamal-backup-restore-', '.sqlite3']) do |tempfile|
|
|
73
73
|
tempfile.close
|
|
74
|
-
restic.write_dump_to_path(snapshot, filename, tempfile.path)
|
|
74
|
+
restic.write_dump_to_path(snapshot, filename, tempfile.path, overwrite: true)
|
|
75
75
|
validate_database_file(tempfile.path)
|
|
76
76
|
FileUtils.mkdir_p(File.dirname(File.expand_path(target)))
|
|
77
77
|
Command.capture(
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'open3'
|
|
3
4
|
require 'shellwords'
|
|
5
|
+
require 'tempfile'
|
|
4
6
|
require 'yaml'
|
|
5
7
|
require_relative 'command'
|
|
6
8
|
require_relative 'yaml_access'
|
|
@@ -11,6 +13,9 @@ module KamalBackup
|
|
|
11
13
|
|
|
12
14
|
DEFAULT_CONFIG_FILE = 'config/deploy.yml'
|
|
13
15
|
VERSION_LINE_PATTERN = /\A\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*\z/
|
|
16
|
+
# Query keys such as auth are credentials even when the URL pattern does not
|
|
17
|
+
# name them. A diagnostic that prints only the value still has to be redacted.
|
|
18
|
+
CREDENTIAL_KEY_PATTERN = /(?:pass|password|secret|token|key|credential|authorization)|(?:\A|_)(?:auth|pwd)(?:\z|_)/i
|
|
14
19
|
|
|
15
20
|
class FilteringIO
|
|
16
21
|
def initialize(io, &reject)
|
|
@@ -84,6 +89,38 @@ module KamalBackup
|
|
|
84
89
|
end
|
|
85
90
|
end
|
|
86
91
|
|
|
92
|
+
# Run restic on the live accessory over SSH + docker exec without Kamal's
|
|
93
|
+
# log wrapper. Kamal accessory exec mixes INFO lines into stdout.
|
|
94
|
+
def capture_restic_command(accessory_name:, argv:, repository: nil, repository_file: nil)
|
|
95
|
+
stdout = +''
|
|
96
|
+
run_restic_on_accessory(
|
|
97
|
+
accessory_name: accessory_name,
|
|
98
|
+
repository: repository,
|
|
99
|
+
repository_file: repository_file,
|
|
100
|
+
argv: argv
|
|
101
|
+
) do |stream|
|
|
102
|
+
stdout << stream.read
|
|
103
|
+
end
|
|
104
|
+
stdout
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Stream a restic dump over SSH + docker exec without Kamal's log wrapper.
|
|
108
|
+
# Kamal accessory exec mixes INFO lines into stdout, which corrupts binary dumps.
|
|
109
|
+
def stream_restic_dump(accessory_name:, snapshot:, filename:, io:, repository: nil, repository_file: nil)
|
|
110
|
+
run_restic_on_accessory(
|
|
111
|
+
accessory_name: accessory_name,
|
|
112
|
+
repository: repository,
|
|
113
|
+
repository_file: repository_file,
|
|
114
|
+
argv: ['dump', snapshot.to_s, filename.to_s],
|
|
115
|
+
snapshot: snapshot,
|
|
116
|
+
filename: filename
|
|
117
|
+
) do |stdout|
|
|
118
|
+
IO.copy_stream(stdout, io)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
true
|
|
122
|
+
end
|
|
123
|
+
|
|
87
124
|
def remote_version(accessory_name:)
|
|
88
125
|
result = execute_on_accessory(accessory_name: accessory_name, command: %w[kamal-backup version])
|
|
89
126
|
version = parse_version_line(result.stdout)
|
|
@@ -95,12 +132,388 @@ module KamalBackup
|
|
|
95
132
|
|
|
96
133
|
private
|
|
97
134
|
|
|
98
|
-
def
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
135
|
+
def run_restic_on_accessory(accessory_name:, argv:, snapshot: nil, filename: nil, repository: nil,
|
|
136
|
+
repository_file: nil)
|
|
137
|
+
config
|
|
138
|
+
target = live_accessory_target(accessory_name)
|
|
139
|
+
unless target
|
|
140
|
+
raise ConfigurationError,
|
|
141
|
+
"could not find a live backup accessory #{accessory_name.inspect} to dump from"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
docker_argv = accessory_restic_docker_argv(
|
|
145
|
+
target.fetch(:service_name),
|
|
146
|
+
argv,
|
|
147
|
+
repository: repository,
|
|
148
|
+
repository_file: repository_file
|
|
149
|
+
)
|
|
150
|
+
remote = docker_argv.shelljoin
|
|
151
|
+
|
|
152
|
+
with_ssh_identity_files do |identity_files, config_file|
|
|
153
|
+
spec = CommandSpec.new(
|
|
154
|
+
argv: ssh_argv(target.fetch(:host), remote, identity_files: identity_files, config_file: config_file)
|
|
155
|
+
)
|
|
156
|
+
Open3.popen3(*spec.argv) do |stdin, stdout, stderr, wait_thread|
|
|
157
|
+
stdin.close
|
|
158
|
+
err_reader = Thread.new { stderr.read }
|
|
159
|
+
yield stdout
|
|
160
|
+
err = err_reader.value
|
|
161
|
+
status = wait_thread.value
|
|
162
|
+
unless status.success?
|
|
163
|
+
raise_restic_accessory_error(
|
|
164
|
+
spec,
|
|
165
|
+
status.exitstatus,
|
|
166
|
+
err,
|
|
167
|
+
snapshot: snapshot,
|
|
168
|
+
filename: filename,
|
|
169
|
+
docker_argv: docker_argv,
|
|
170
|
+
redactor: diagnostic_redactor(repository)
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The accessory command loads config/kamal-backup.yml itself. Repository URLs,
|
|
178
|
+
# repository files, password files, and password commands stay in that process
|
|
179
|
+
# and are not repeated on the SSH command line.
|
|
180
|
+
def accessory_restic_docker_argv(service_name, argv, repository:, repository_file:)
|
|
181
|
+
command = ['docker', 'exec', service_name, 'kamal-backup', 'run-restic', '--', *Array(argv).map(&:to_s)]
|
|
182
|
+
forbidden = [repository, repository_file].compact.map(&:to_s).reject(&:empty?)
|
|
183
|
+
leaked = forbidden.intersect?(command) || command.any? { |arg| arg.include?('RESTIC_REPOSITORY') }
|
|
184
|
+
raise ConfigurationError, 'refusing to place repository settings on the accessory command' if leaked
|
|
185
|
+
|
|
186
|
+
command
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def raise_restic_accessory_error(spec, status, stderr, snapshot:, filename:, docker_argv: nil, redactor: nil)
|
|
190
|
+
reporter = redactor || @redactor
|
|
191
|
+
redacted = reporter.redact_string(stderr.to_s)
|
|
192
|
+
dump_context = snapshot && filename
|
|
193
|
+
message =
|
|
194
|
+
if dump_context && stderr.to_s.match?(/no matching ID found|failed to find snapshot|no snapshot found/i)
|
|
195
|
+
"backup not found for snapshot #{snapshot.inspect}"
|
|
196
|
+
elsif dump_context && missing_requested_dump_file?(stderr, filename)
|
|
197
|
+
"backup file #{filename.inspect} not found in snapshot #{snapshot.inspect}"
|
|
198
|
+
else
|
|
199
|
+
"command failed (#{status}): #{redacted_restic_command(spec, docker_argv, reporter)}\n#{redacted}"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
raise CommandError.new(
|
|
203
|
+
message,
|
|
204
|
+
command: spec,
|
|
205
|
+
status: status,
|
|
206
|
+
stderr: redacted
|
|
207
|
+
)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# A missing password file or repository file also says "no such file". Only
|
|
211
|
+
# the requested dump path is a missing backup.
|
|
212
|
+
def missing_requested_dump_file?(stderr, filename)
|
|
213
|
+
text = stderr.to_s
|
|
214
|
+
return false unless text.match?(/not found|does not exist|no such file/i)
|
|
215
|
+
|
|
216
|
+
[filename.to_s, filename.to_s.sub(%r{\A/+}, '')].uniq.reject(&:empty?).any? do |path|
|
|
217
|
+
text.include?(path)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Shell-escaping hides query credentials from a later redactor pass.
|
|
222
|
+
# Redact each argument first, then escape that copy.
|
|
223
|
+
def redacted_restic_command(spec, docker_argv, redactor)
|
|
224
|
+
return spec.display(redactor) unless docker_argv
|
|
225
|
+
|
|
226
|
+
prefix = spec.argv[0..-2].map { |arg| redactor.redact_string(arg) }.shelljoin
|
|
227
|
+
remote = docker_argv.map { |arg| redactor.redact_string(arg) }.shelljoin
|
|
228
|
+
"#{prefix} #{remote}"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def diagnostic_redactor(repository)
|
|
232
|
+
@redactor.with_additional_secrets(configured_credential_values(repository))
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def configured_credential_values(repository)
|
|
236
|
+
[repository, ssh_proxy_command(ssh_options[:proxy])].flat_map { |source| credential_values_in(source) }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def credential_values_in(source)
|
|
240
|
+
text = source.to_s
|
|
241
|
+
return [] if text.strip.empty?
|
|
242
|
+
|
|
243
|
+
url_credential_values(text) + option_credential_values(text)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def url_credential_values(text)
|
|
247
|
+
values = []
|
|
248
|
+
text.scan(%r{://[^/\s@:]+:([^/\s@]+)@}) { values << Regexp.last_match(1) }
|
|
249
|
+
text.scan(/[?&]([A-Za-z0-9][\w.-]*)=([^&#\s]+)/) do |key, value|
|
|
250
|
+
values << value if key.match?(CREDENTIAL_KEY_PATTERN)
|
|
251
|
+
end
|
|
252
|
+
values
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Shellwords removes quotes, so --token 'secret' and --token "secret" register
|
|
256
|
+
# the secret itself. A nested sh -c command is parsed again. A broken quote
|
|
257
|
+
# must not raise while an error is being reported.
|
|
258
|
+
def option_credential_values(text, depth: 0)
|
|
259
|
+
return [] if depth > 4 || text.strip.empty?
|
|
260
|
+
|
|
261
|
+
words = split_proxy_command(text)
|
|
262
|
+
values = []
|
|
263
|
+
words.each_with_index do |word, index|
|
|
264
|
+
values.concat(option_values_at(words, word, index))
|
|
265
|
+
values.concat(url_credential_values(word)) if word.match?(%r{://|[?&][\w.-]+=})
|
|
266
|
+
next if word == text || !word.match?(/--/) || !word.match?(/[\s'"]/)
|
|
267
|
+
|
|
268
|
+
values.concat(option_credential_values(word, depth: depth + 1))
|
|
103
269
|
end
|
|
270
|
+
values
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def option_values_at(words, word, index)
|
|
274
|
+
if (assignment = word.match(/\A--([A-Za-z0-9][\w-]*)=(.*)\z/m))
|
|
275
|
+
return [] unless assignment[1].match?(CREDENTIAL_KEY_PATTERN)
|
|
276
|
+
|
|
277
|
+
value = assignment[2]
|
|
278
|
+
return value.empty? ? [] : [value]
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
flag = word.match(/\A--([A-Za-z0-9][\w-]*)\z/)
|
|
282
|
+
return [] unless flag && flag[1].match?(CREDENTIAL_KEY_PATTERN)
|
|
283
|
+
|
|
284
|
+
value = words[index + 1]
|
|
285
|
+
return [] if value.nil? || value.empty? || value.start_with?('-')
|
|
286
|
+
|
|
287
|
+
[value]
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def split_proxy_command(text)
|
|
291
|
+
Shellwords.split(text)
|
|
292
|
+
rescue ArgumentError
|
|
293
|
+
lenient_shell_split(text)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def lenient_shell_split(text)
|
|
297
|
+
words = []
|
|
298
|
+
word = +''
|
|
299
|
+
quoted = nil
|
|
300
|
+
started = false
|
|
301
|
+
index = 0
|
|
302
|
+
while index < text.length
|
|
303
|
+
char = text[index]
|
|
304
|
+
if quoted
|
|
305
|
+
if char == quoted
|
|
306
|
+
quoted = nil
|
|
307
|
+
elsif quoted == '"' && char == '\\' && index + 1 < text.length
|
|
308
|
+
index += 1
|
|
309
|
+
word << text[index]
|
|
310
|
+
else
|
|
311
|
+
word << char
|
|
312
|
+
end
|
|
313
|
+
started = true
|
|
314
|
+
elsif char == '\\' && index + 1 < text.length
|
|
315
|
+
index += 1
|
|
316
|
+
word << text[index]
|
|
317
|
+
started = true
|
|
318
|
+
elsif ["'", '"'].include?(char)
|
|
319
|
+
quoted = char
|
|
320
|
+
started = true
|
|
321
|
+
elsif char.match?(/\s/)
|
|
322
|
+
if started
|
|
323
|
+
words << word
|
|
324
|
+
word = +''
|
|
325
|
+
started = false
|
|
326
|
+
end
|
|
327
|
+
else
|
|
328
|
+
word << char
|
|
329
|
+
started = true
|
|
330
|
+
end
|
|
331
|
+
index += 1
|
|
332
|
+
end
|
|
333
|
+
words << word if started
|
|
334
|
+
words
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Match Kamal's SSH defaults: user root, port 22, plus ssh.user, port, proxy,
|
|
338
|
+
# keys, and config from the rendered deploy config. -T overrides RequestTTY
|
|
339
|
+
# force so a config file cannot translate dump bytes or merge stderr into stdout.
|
|
340
|
+
def ssh_argv(host, remote_command, identity_files:, config_file:)
|
|
341
|
+
options = ssh_options
|
|
342
|
+
argv = ['ssh', '-T', '-p', options.fetch(:port), '-l', options.fetch(:user)]
|
|
343
|
+
argv.concat(ssh_proxy_args(options[:proxy]))
|
|
344
|
+
Array(options[:keys]).each { |key| argv.concat(['-i', key]) }
|
|
345
|
+
identity_files.each { |path| argv.concat(['-i', path]) }
|
|
346
|
+
argv.concat(['-o', 'IdentitiesOnly=yes']) if options[:keys_only]
|
|
347
|
+
argv.concat(['-F', '/dev/null']) if options[:config] == false && config_file.nil?
|
|
348
|
+
argv.concat(['-F', config_file]) if config_file
|
|
349
|
+
case options[:forward_agent]
|
|
350
|
+
when true
|
|
351
|
+
argv.concat(['-o', 'ForwardAgent=yes'])
|
|
352
|
+
when false
|
|
353
|
+
argv.concat(['-o', 'ForwardAgent=no'])
|
|
354
|
+
end
|
|
355
|
+
argv << host
|
|
356
|
+
argv << remote_command
|
|
357
|
+
argv
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def ssh_options
|
|
361
|
+
raw = fetch(config, :ssh_options) || {}
|
|
362
|
+
{
|
|
363
|
+
user: (ssh_config_value(raw, :user) || 'root').to_s,
|
|
364
|
+
port: (ssh_config_value(raw, :port) || 22).to_s,
|
|
365
|
+
proxy: ssh_config_value(raw, :proxy),
|
|
366
|
+
keys: Array(ssh_config_value(raw, :keys)).map { |key| File.expand_path(key.to_s) },
|
|
367
|
+
keys_only: ssh_config_value(raw, :keys_only),
|
|
368
|
+
config: ssh_config_value(raw, :config),
|
|
369
|
+
forward_agent: ssh_config_value(raw, :forward_agent),
|
|
370
|
+
key_data: Array(ssh_config_value(raw, :key_data)).map(&:to_s).reject(&:empty?)
|
|
371
|
+
}
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def ssh_config_value(raw, key)
|
|
375
|
+
[key, key.to_s, key.to_sym].each do |candidate|
|
|
376
|
+
return raw[candidate] if raw.key?(candidate)
|
|
377
|
+
end
|
|
378
|
+
nil
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def ssh_proxy_args(proxy)
|
|
382
|
+
return [] if proxy.nil? || proxy == false
|
|
383
|
+
return [] unless (jump = ssh_jump_target(proxy))
|
|
384
|
+
|
|
385
|
+
jump = "root@#{jump}" unless jump.include?('@') || jump.include?(',')
|
|
386
|
+
['-J', jump]
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def ssh_jump_target(proxy)
|
|
390
|
+
raw = if proxy.is_a?(String)
|
|
391
|
+
proxy
|
|
392
|
+
elsif proxy.respond_to?(:jump_proxies)
|
|
393
|
+
proxy.jump_proxies
|
|
394
|
+
elsif proxy.is_a?(Hash)
|
|
395
|
+
fetch(proxy, :jump_proxies)
|
|
396
|
+
end
|
|
397
|
+
value = raw.to_s.strip
|
|
398
|
+
return if value.empty? || value.include?(' ')
|
|
399
|
+
|
|
400
|
+
value
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def ssh_proxy_command(proxy)
|
|
404
|
+
raw = if proxy.is_a?(String)
|
|
405
|
+
proxy
|
|
406
|
+
elsif proxy.respond_to?(:command_line_template) && !proxy.respond_to?(:jump_proxies)
|
|
407
|
+
proxy.command_line_template
|
|
408
|
+
elsif proxy.is_a?(Hash)
|
|
409
|
+
fetch(proxy, :command_line_template) || fetch(proxy, :command)
|
|
410
|
+
end
|
|
411
|
+
value = raw.to_s.strip
|
|
412
|
+
return if value.empty?
|
|
413
|
+
|
|
414
|
+
value
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def with_ssh_identity_files
|
|
418
|
+
identity_files = []
|
|
419
|
+
config_file = ssh_config_file
|
|
420
|
+
ssh_options.fetch(:key_data).each do |data|
|
|
421
|
+
file = Tempfile.new(['kamal-backup-ssh-', '.key'])
|
|
422
|
+
file.chmod(0o600)
|
|
423
|
+
file.write(data)
|
|
424
|
+
file.close
|
|
425
|
+
identity_files << file
|
|
426
|
+
end
|
|
427
|
+
yield identity_files.map(&:path), config_file&.path
|
|
428
|
+
ensure
|
|
429
|
+
(Array(identity_files) + [config_file]).compact.each do |file|
|
|
430
|
+
file.close! if file.respond_to?(:close!)
|
|
431
|
+
rescue StandardError
|
|
432
|
+
nil
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def ssh_config_file
|
|
437
|
+
lines = []
|
|
438
|
+
# First value wins. Keep ProxyCommand ahead of Include so a credential in
|
|
439
|
+
# the deploy config is not replaced by a later file, and so it never
|
|
440
|
+
# appears in the ssh process arguments.
|
|
441
|
+
if (command = ssh_proxy_directive)
|
|
442
|
+
raise ConfigurationError, 'SSH proxy command cannot contain a newline' if command.match?(/[\r\n]/)
|
|
443
|
+
|
|
444
|
+
lines << "ProxyCommand #{command}"
|
|
445
|
+
end
|
|
446
|
+
lines.concat(ssh_config_include_lines)
|
|
447
|
+
return if lines.empty?
|
|
448
|
+
|
|
449
|
+
file = Tempfile.new(['kamal-backup-ssh-config-', '.conf'])
|
|
450
|
+
file.chmod(0o600)
|
|
451
|
+
file.write("#{lines.join("\n")}\n")
|
|
452
|
+
file.close
|
|
453
|
+
file
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def ssh_proxy_directive
|
|
457
|
+
proxy = ssh_options[:proxy]
|
|
458
|
+
return if proxy.nil? || proxy == false
|
|
459
|
+
return if ssh_jump_target(proxy)
|
|
460
|
+
|
|
461
|
+
ssh_proxy_command(proxy)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def ssh_config_include_lines
|
|
465
|
+
paths = explicit_ssh_config_paths
|
|
466
|
+
# -F hides the files OpenSSH would read on its own. Put them back when the
|
|
467
|
+
# deploy config left ssh.config on and the only reason for -F is the proxy.
|
|
468
|
+
paths = default_ssh_config_paths if paths.empty? && ssh_options[:config] != false && ssh_proxy_directive
|
|
469
|
+
|
|
470
|
+
paths.map { |path| File.expand_path(path) }.reject(&:empty?).map do |path|
|
|
471
|
+
%(Include "#{path.gsub(/["\\]/) { |char| "\\#{char}" }}")
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def explicit_ssh_config_paths
|
|
476
|
+
case ssh_options[:config]
|
|
477
|
+
when String
|
|
478
|
+
[ssh_options[:config]]
|
|
479
|
+
when Array
|
|
480
|
+
ssh_options[:config].map(&:to_s)
|
|
481
|
+
else
|
|
482
|
+
[]
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def default_ssh_config_paths
|
|
487
|
+
home = ENV.fetch('HOME', '').to_s
|
|
488
|
+
candidates = []
|
|
489
|
+
candidates << File.join(home, '.ssh', 'config') unless home.empty?
|
|
490
|
+
candidates << '/etc/ssh/ssh_config'
|
|
491
|
+
candidates << '/etc/ssh_config'
|
|
492
|
+
candidates.select { |path| File.file?(path) }
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def config
|
|
496
|
+
@config ||= load_kamal_config(capture_kamal(kamal_config_argv).stdout)
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# kamal config renders ssh.proxy and ssh.proxy_command as Net::SSH::Proxy
|
|
500
|
+
# objects. kamal-backup does not load net-ssh, so read any object whose
|
|
501
|
+
# class is not loaded as a Hash of its instance variables.
|
|
502
|
+
def load_kamal_config(yaml)
|
|
503
|
+
document = Psych.parse_stream(yaml).children.first
|
|
504
|
+
return false unless document
|
|
505
|
+
|
|
506
|
+
document.each { |node| node.tag = nil if unloaded_ruby_object?(node) }
|
|
507
|
+
document.to_ruby
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def unloaded_ruby_object?(node)
|
|
511
|
+
return false unless node.is_a?(Psych::Nodes::Mapping) && node.tag&.start_with?('!ruby/object:')
|
|
512
|
+
|
|
513
|
+
Object.const_get(node.tag.delete_prefix('!ruby/object:'))
|
|
514
|
+
false
|
|
515
|
+
rescue NameError
|
|
516
|
+
true
|
|
104
517
|
end
|
|
105
518
|
|
|
106
519
|
def accessories
|
|
@@ -296,8 +709,6 @@ module KamalBackup
|
|
|
296
709
|
end
|
|
297
710
|
|
|
298
711
|
def live_accessory_target(accessory_name)
|
|
299
|
-
return unless defined?(@config)
|
|
300
|
-
|
|
301
712
|
accessory_config = accessory(accessory_name)
|
|
302
713
|
host = single_accessory_host(accessory_config)
|
|
303
714
|
service_name = fetch(accessory_config, :service) || default_accessory_service_name(accessory_name)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'tempfile'
|
|
5
|
+
require_relative 'errors'
|
|
6
|
+
|
|
7
|
+
module KamalBackup
|
|
8
|
+
# Same-directory dump output that is created exclusively and kept owner-only.
|
|
9
|
+
# A predictable path opened with truncation follows a pre-created symlink and
|
|
10
|
+
# inherits the process umask.
|
|
11
|
+
class PrivateTempfile
|
|
12
|
+
MODE = 0o600
|
|
13
|
+
UNSUPPORTED_LINK_ERRORS = [Errno::EPERM, Errno::ENOTSUP, Errno::EOPNOTSUPP, Errno::EXDEV].uniq.freeze
|
|
14
|
+
|
|
15
|
+
def self.open(target_path)
|
|
16
|
+
directory = File.dirname(target_path)
|
|
17
|
+
file = Tempfile.create(
|
|
18
|
+
["#{File.basename(target_path)}.kamal-backup-", '.tmp'],
|
|
19
|
+
directory,
|
|
20
|
+
mode: File::BINARY
|
|
21
|
+
)
|
|
22
|
+
file.chmod(MODE)
|
|
23
|
+
file
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# File.rename always replaces the destination. Without overwrite authorization,
|
|
27
|
+
# File.link fails with EEXIST if that name already exists, including a file
|
|
28
|
+
# created after the earlier confirmation check.
|
|
29
|
+
def self.publish(file, target_path, overwrite:)
|
|
30
|
+
file.flush
|
|
31
|
+
file.close
|
|
32
|
+
if overwrite
|
|
33
|
+
File.rename(file.path, target_path)
|
|
34
|
+
else
|
|
35
|
+
link_exclusively(file.path, target_path)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.discard(file)
|
|
40
|
+
return unless file
|
|
41
|
+
|
|
42
|
+
file.close unless file.closed?
|
|
43
|
+
FileUtils.rm_f(file.path) if file.path
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.link_exclusively(source, target_path)
|
|
47
|
+
File.link(source, target_path)
|
|
48
|
+
File.unlink(source)
|
|
49
|
+
rescue Errno::EEXIST
|
|
50
|
+
raise ConfigurationError, "output file already exists: #{target_path}; pass --yes to overwrite"
|
|
51
|
+
rescue *UNSUPPORTED_LINK_ERRORS
|
|
52
|
+
copy_exclusively(source, target_path)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Exclusive create still refuses a name that appeared during the download.
|
|
56
|
+
# A failed or interrupted copy removes that new file so a partial dump is not
|
|
57
|
+
# left behind. copied is set only after the file closes, so a close error is
|
|
58
|
+
# cleaned up too. Interrupt is not a StandardError, so this uses ensure.
|
|
59
|
+
def self.copy_exclusively(source, target_path)
|
|
60
|
+
created = false
|
|
61
|
+
copied = false
|
|
62
|
+
File.open(target_path, File::WRONLY | File::CREAT | File::EXCL | File::BINARY, MODE) do |out|
|
|
63
|
+
created = true
|
|
64
|
+
out.chmod(MODE)
|
|
65
|
+
File.open(source, File::RDONLY | File::BINARY) { |input| IO.copy_stream(input, out) }
|
|
66
|
+
end
|
|
67
|
+
copied = true
|
|
68
|
+
File.unlink(source)
|
|
69
|
+
rescue Errno::EEXIST
|
|
70
|
+
raise ConfigurationError, "output file already exists: #{target_path}; pass --yes to overwrite"
|
|
71
|
+
ensure
|
|
72
|
+
remove_partial_copy(target_path) if created && !copied
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.remove_partial_copy(path)
|
|
76
|
+
File.unlink(path)
|
|
77
|
+
rescue Errno::ENOENT
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
private_class_method :link_exclusively, :copy_exclusively, :remove_partial_copy
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -32,6 +32,10 @@ module KamalBackup
|
|
|
32
32
|
redacted
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
def with_additional_secrets(values)
|
|
36
|
+
self.class.new(secret_values: @secret_values + Array(values), env: @env)
|
|
37
|
+
end
|
|
38
|
+
|
|
35
39
|
private
|
|
36
40
|
|
|
37
41
|
def known_secret_values
|
|
@@ -40,7 +44,8 @@ module KamalBackup
|
|
|
40
44
|
values << value.to_s if key.to_s.match?(SECRET_KEY_PATTERN)
|
|
41
45
|
end
|
|
42
46
|
|
|
43
|
-
(@secret_values + env_secrets).compact.uniq
|
|
47
|
+
secrets = (@secret_values + env_secrets).compact.uniq
|
|
48
|
+
secrets.reject { |value| value.empty? || value.length < 4 }.sort_by { |value| -value.length }
|
|
44
49
|
end
|
|
45
50
|
end
|
|
46
51
|
|
data/lib/kamal_backup/restic.rb
CHANGED
|
@@ -6,6 +6,7 @@ require 'json'
|
|
|
6
6
|
require 'open3'
|
|
7
7
|
require 'time'
|
|
8
8
|
require_relative 'command'
|
|
9
|
+
require_relative 'private_tempfile'
|
|
9
10
|
|
|
10
11
|
module KamalBackup
|
|
11
12
|
class Restic
|
|
@@ -13,9 +14,10 @@ module KamalBackup
|
|
|
13
14
|
|
|
14
15
|
attr_reader :config, :redactor
|
|
15
16
|
|
|
16
|
-
def initialize(config, redactor:)
|
|
17
|
+
def initialize(config, redactor:, runner: nil)
|
|
17
18
|
@config = config
|
|
18
19
|
@redactor = redactor
|
|
20
|
+
@runner = runner
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def ensure_repository
|
|
@@ -173,34 +175,57 @@ module KamalBackup
|
|
|
173
175
|
pipe_commands(restic_command, command, producer_label: 'restic dump', consumer_label: command.argv.first)
|
|
174
176
|
end
|
|
175
177
|
|
|
176
|
-
def write_dump_to_path(snapshot, filename, target_path)
|
|
178
|
+
def write_dump_to_path(snapshot, filename, target_path, overwrite: false)
|
|
177
179
|
command = CommandSpec.new(argv: ['restic', 'dump', snapshot, filename], env: restic_env)
|
|
178
180
|
target_path = File.expand_path(target_path)
|
|
179
|
-
|
|
180
|
-
temp_path = "#{target_path}.kamal-backup-#{$PROCESS_ID}.tmp"
|
|
181
|
+
raise ConfigurationError, "output path must be a file, not a directory: #{target_path}" if File.directory?(target_path)
|
|
181
182
|
|
|
183
|
+
parent = File.dirname(target_path)
|
|
184
|
+
raise ConfigurationError, "output path directory does not exist: #{parent}" unless File.directory?(parent)
|
|
185
|
+
|
|
186
|
+
temp = nil
|
|
182
187
|
output = Command.output
|
|
183
188
|
context = output&.command_start(command, redactor: redactor)
|
|
189
|
+
temp = PrivateTempfile.open(target_path)
|
|
184
190
|
Open3.popen3(command.env, *command.argv) do |stdin, stdout, stderr, wait_thread|
|
|
185
191
|
stdin.close
|
|
186
192
|
stderr_reader = Thread.new do
|
|
187
193
|
Command.collect_stream(stderr, command_output: output, context: context, stream: :stderr, redactor: redactor)
|
|
188
194
|
end
|
|
189
|
-
|
|
195
|
+
IO.copy_stream(stdout, temp)
|
|
190
196
|
err = stderr_reader.value
|
|
191
197
|
status = wait_thread.value
|
|
192
198
|
output&.command_exit(context, status.exitstatus)
|
|
193
199
|
raise_command_error(command, status, '', err) unless status.success?
|
|
194
200
|
end
|
|
195
|
-
|
|
201
|
+
PrivateTempfile.publish(temp, target_path, overwrite: overwrite)
|
|
196
202
|
target_path
|
|
197
203
|
rescue Errno::ENOENT => e
|
|
198
|
-
FileUtils.rm_f(temp_path) if temp_path
|
|
199
204
|
raise CommandError.new("command not found: #{command.argv.first}", command: command, status: 127,
|
|
200
205
|
stderr: e.message)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
206
|
+
ensure
|
|
207
|
+
PrivateTempfile.discard(temp)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def pipe_dump_to_io(snapshot, filename, io)
|
|
211
|
+
command = CommandSpec.new(argv: ['restic', 'dump', snapshot, filename], env: restic_env)
|
|
212
|
+
output = Command.output
|
|
213
|
+
context = output&.command_start(command, redactor: redactor)
|
|
214
|
+
Open3.popen3(command.env, *command.argv) do |stdin, stdout, stderr, wait_thread|
|
|
215
|
+
stdin.close
|
|
216
|
+
stderr_reader = Thread.new do
|
|
217
|
+
Command.collect_stream(stderr, command_output: output, context: context, stream: :stderr, redactor: redactor)
|
|
218
|
+
end
|
|
219
|
+
IO.copy_stream(stdout, io)
|
|
220
|
+
err = stderr_reader.value
|
|
221
|
+
status = wait_thread.value
|
|
222
|
+
output&.command_exit(context, status.exitstatus)
|
|
223
|
+
raise_command_error(command, status, '', err) unless status.success?
|
|
224
|
+
end
|
|
225
|
+
true
|
|
226
|
+
rescue Errno::ENOENT => e
|
|
227
|
+
raise CommandError.new("command not found: #{command.argv.first}", command: command, status: 127,
|
|
228
|
+
stderr: e.message)
|
|
204
229
|
end
|
|
205
230
|
|
|
206
231
|
def restore_snapshot(snapshot, target)
|
|
@@ -208,9 +233,17 @@ module KamalBackup
|
|
|
208
233
|
run(['restore', snapshot, '--target', target])
|
|
209
234
|
end
|
|
210
235
|
|
|
236
|
+
def self.environment_for(config)
|
|
237
|
+
config.env.each_with_object({}) do |(key, value), env|
|
|
238
|
+
env[key] = value if key.to_s.match?(RESTIC_ENV_PATTERN)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
211
242
|
private
|
|
212
243
|
|
|
213
244
|
def run(args, log_output: true, env: restic_env)
|
|
245
|
+
return @runner.call(args, log_output: log_output, env: env) if @runner
|
|
246
|
+
|
|
214
247
|
Command.capture(
|
|
215
248
|
CommandSpec.new(argv: ['restic'] + args, env: env),
|
|
216
249
|
redactor: redactor,
|
|
@@ -284,9 +317,7 @@ module KamalBackup
|
|
|
284
317
|
end
|
|
285
318
|
|
|
286
319
|
def restic_env
|
|
287
|
-
|
|
288
|
-
env[key] = value if key.to_s.match?(RESTIC_ENV_PATTERN)
|
|
289
|
-
end
|
|
320
|
+
self.class.environment_for(config)
|
|
290
321
|
end
|
|
291
322
|
|
|
292
323
|
def pipe_commands(producer, consumer, producer_label:, consumer_label:)
|
data/lib/kamal_backup/version.rb
CHANGED
data/lib/kamal_backup.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative 'kamal_backup/errors'
|
|
|
6
6
|
require_relative 'kamal_backup/yaml_access'
|
|
7
7
|
require_relative 'kamal_backup/command'
|
|
8
8
|
require_relative 'kamal_backup/command_output'
|
|
9
|
+
require_relative 'kamal_backup/private_tempfile'
|
|
9
10
|
require_relative 'kamal_backup/redactor'
|
|
10
11
|
require_relative 'kamal_backup/config_file'
|
|
11
12
|
require_relative 'kamal_backup/config'
|
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: 1.
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- crmne
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: thor
|
|
@@ -134,6 +134,7 @@ files:
|
|
|
134
134
|
- lib/kamal_backup/errors.rb
|
|
135
135
|
- lib/kamal_backup/evidence.rb
|
|
136
136
|
- lib/kamal_backup/kamal_bridge.rb
|
|
137
|
+
- lib/kamal_backup/private_tempfile.rb
|
|
137
138
|
- lib/kamal_backup/rails_app.rb
|
|
138
139
|
- lib/kamal_backup/redactor.rb
|
|
139
140
|
- lib/kamal_backup/restic.rb
|