kamal-backup 0.3.0.beta4 → 0.3.0.beta6
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 +1 -1
- data/lib/kamal_backup/app.rb +2 -7
- data/lib/kamal_backup/cli.rb +22 -10
- data/lib/kamal_backup/command.rb +181 -5
- data/lib/kamal_backup/databases/base.rb +7 -7
- data/lib/kamal_backup/databases/sqlite.rb +3 -3
- data/lib/kamal_backup/kamal_bridge.rb +14 -6
- data/lib/kamal_backup/restic.rb +50 -16
- data/lib/kamal_backup/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 68d3201dc26c940938479dc76d1c0e17b206676ae69610d736a69879a78de21c
|
|
4
|
+
data.tar.gz: dd58ee38da83d4652515b108869e5fe441a25d80697b7db3ea1c606240a44766
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 37ca5f6463fe6eb76c2f6994a783e2180f3eecff24e834550743c1f9130e6ad3019ce1e296dc44ac523a36c065f1e05ac00aaaec7c7224254daea0c2d683d7a0
|
|
7
|
+
data.tar.gz: '0985cd5e2eb8a52db5dcc4265271eddde1513c8c8db80d0aec21ee3ca1be0bd42e2adcd60ffeb942aa7bb2d926cbad69f5d89051485ce64145f3f5c821abe3bb'
|
data/README.md
CHANGED
|
@@ -141,7 +141,7 @@ Run the release helper from a clean `master` checkout:
|
|
|
141
141
|
bin/release 0.2.9
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
-
It updates `lib/kamal_backup/version.rb`,
|
|
144
|
+
It updates `lib/kamal_backup/version.rb`, syncs `Gemfile.lock`, commits `Release 0.2.9`, and pushes `master`. CI runs the test suite and docs build, publishes the RubyGem and Docker image tags, then creates `v0.2.9`, the GitHub release, and the docs deployment from the release commit.
|
|
145
145
|
|
|
146
146
|
Use `bin/release 0.2.9 --no-push` to prepare the commit locally without publishing.
|
|
147
147
|
|
data/lib/kamal_backup/app.rb
CHANGED
|
@@ -31,10 +31,9 @@ module KamalBackup
|
|
|
31
31
|
config.validate_backup
|
|
32
32
|
require_restic!
|
|
33
33
|
|
|
34
|
-
timestamp = current_timestamp
|
|
35
34
|
restic.ensure_repository
|
|
36
|
-
databases.each { |database| database.backup(restic
|
|
37
|
-
restic.backup_paths(config.backup_paths, tags: ["type:files"
|
|
35
|
+
databases.each { |database| database.backup(restic) }
|
|
36
|
+
restic.backup_paths(config.backup_paths, tags: ["type:files"])
|
|
38
37
|
|
|
39
38
|
if config.forget_after_backup?
|
|
40
39
|
restic.forget_after_success
|
|
@@ -135,10 +134,6 @@ module KamalBackup
|
|
|
135
134
|
end
|
|
136
135
|
|
|
137
136
|
private
|
|
138
|
-
def current_timestamp
|
|
139
|
-
Time.now.utc.strftime("%Y%m%dT%H%M%SZ")
|
|
140
|
-
end
|
|
141
|
-
|
|
142
137
|
def build_restore_result(scope, snapshot)
|
|
143
138
|
started_at = Time.now.utc
|
|
144
139
|
result = Schema.record(
|
data/lib/kamal_backup/cli.rb
CHANGED
|
@@ -20,11 +20,17 @@ module KamalBackup
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def direct_app
|
|
23
|
-
@direct_app ||= App.new(
|
|
23
|
+
@direct_app ||= App.new(
|
|
24
|
+
config: Config.new(env: command_env),
|
|
25
|
+
redactor: redactor
|
|
26
|
+
)
|
|
24
27
|
end
|
|
25
28
|
|
|
26
|
-
def
|
|
27
|
-
@
|
|
29
|
+
def local_restore_app
|
|
30
|
+
@local_restore_app ||= App.new(
|
|
31
|
+
config: local_command_config,
|
|
32
|
+
redactor: redactor
|
|
33
|
+
)
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
def local_preferences
|
|
@@ -65,7 +71,9 @@ module KamalBackup
|
|
|
65
71
|
redactor: redactor,
|
|
66
72
|
config_file: options[:config_file],
|
|
67
73
|
destination: options[:destination],
|
|
68
|
-
env: command_env
|
|
74
|
+
env: command_env,
|
|
75
|
+
stdout: $stdout,
|
|
76
|
+
stderr: $stderr
|
|
69
77
|
)
|
|
70
78
|
end
|
|
71
79
|
|
|
@@ -94,9 +102,11 @@ module KamalBackup
|
|
|
94
102
|
|
|
95
103
|
result = bridge.execute_on_accessory(
|
|
96
104
|
accessory_name: accessory_name,
|
|
97
|
-
command: Shellwords.join(argv)
|
|
105
|
+
command: Shellwords.join(argv),
|
|
106
|
+
stream: true
|
|
98
107
|
)
|
|
99
|
-
print(result.stdout)
|
|
108
|
+
print(result.stdout) unless result.streamed
|
|
109
|
+
$stderr.print(result.stderr) if !result.streamed && !result.stderr.empty?
|
|
100
110
|
result
|
|
101
111
|
end
|
|
102
112
|
|
|
@@ -276,7 +286,7 @@ module KamalBackup
|
|
|
276
286
|
desc "local [SNAPSHOT]", "Restore the backup into the local database and Active Storage path"
|
|
277
287
|
def local(snapshot = "latest")
|
|
278
288
|
confirm!("Restore #{snapshot} into the local database and Active Storage path? This will overwrite local data.")
|
|
279
|
-
puts(JSON.pretty_generate(
|
|
289
|
+
puts(JSON.pretty_generate(local_restore_app.restore_to_local_machine(snapshot)))
|
|
280
290
|
end
|
|
281
291
|
|
|
282
292
|
method_option :"confirm-production-restore", type: :boolean, default: false, desc: "Confirm production restore without interactive prompts"
|
|
@@ -301,9 +311,9 @@ module KamalBackup
|
|
|
301
311
|
desc "local [SNAPSHOT]", "Run a restore drill on the local machine"
|
|
302
312
|
def local(snapshot = "latest")
|
|
303
313
|
confirm!("Run a local restore drill for #{snapshot}? This will overwrite local data.")
|
|
304
|
-
result =
|
|
314
|
+
result = local_restore_app.drill_on_local_machine(snapshot, check_command: options[:check])
|
|
305
315
|
puts(JSON.pretty_generate(result))
|
|
306
|
-
exit(1) if
|
|
316
|
+
exit(1) if local_restore_app.drill_failed?(result)
|
|
307
317
|
end
|
|
308
318
|
|
|
309
319
|
method_option :database, type: :string, desc: "Scratch database name for PostgreSQL or MySQL"
|
|
@@ -389,7 +399,9 @@ module KamalBackup
|
|
|
389
399
|
|
|
390
400
|
def self.start(argv = ARGV, env: ENV)
|
|
391
401
|
self.command_env = env
|
|
392
|
-
|
|
402
|
+
Command.with_output(CommandOutput.new(io: $stderr)) do
|
|
403
|
+
super(normalize_global_options(argv))
|
|
404
|
+
end
|
|
393
405
|
rescue Error => e
|
|
394
406
|
warn("kamal-backup: #{Redactor.new(env: env).redact_string(e.message)}")
|
|
395
407
|
exit(1)
|
data/lib/kamal_backup/command.rb
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
require "open3"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
require "shellwords"
|
|
2
4
|
require_relative "errors"
|
|
3
5
|
|
|
4
6
|
module KamalBackup
|
|
@@ -18,14 +20,109 @@ module KamalBackup
|
|
|
18
20
|
|
|
19
21
|
def display(redactor)
|
|
20
22
|
env_prefix = env.keys.sort.map { |key| "#{key}=#{redactor.redact_value(key, env[key])}" }
|
|
21
|
-
redactor.redact_string((env_prefix + argv).join(" "))
|
|
23
|
+
redactor.redact_string((env_prefix + [argv.shelljoin]).join(" "))
|
|
22
24
|
end
|
|
23
25
|
end
|
|
24
26
|
|
|
25
|
-
CommandResult = Struct.new(:stdout, :stderr, :status, keyword_init: true)
|
|
27
|
+
CommandResult = Struct.new(:stdout, :stderr, :status, :streamed, keyword_init: true)
|
|
28
|
+
|
|
29
|
+
class CommandOutput
|
|
30
|
+
def initialize(io: $stdout)
|
|
31
|
+
@io = io
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
@buffers = {}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def info(message, redactor:)
|
|
37
|
+
write_line(" INFO #{redactor.redact_string(message)}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def command_start(spec, redactor:)
|
|
41
|
+
id = SecureRandom.hex(4)
|
|
42
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
43
|
+
display = spec.display(redactor)
|
|
44
|
+
|
|
45
|
+
write_line(" INFO [#{id}] Running #{display} locally")
|
|
46
|
+
write_line(" DEBUG [#{id}] Command: #{display}")
|
|
47
|
+
|
|
48
|
+
{ id: id, started_at: started_at, redactor: redactor }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def command_output(context, _stream, data, redactor:)
|
|
52
|
+
raw = data.to_s
|
|
53
|
+
return if raw.empty?
|
|
54
|
+
|
|
55
|
+
synchronize do
|
|
56
|
+
key = [context.fetch(:id), _stream]
|
|
57
|
+
@buffers[key] = "#{@buffers[key]}#{raw}"
|
|
58
|
+
flush_complete_output_lines(context, key, redactor: redactor)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def command_exit(context, status)
|
|
63
|
+
runtime = Process.clock_gettime(Process::CLOCK_MONOTONIC) - context.fetch(:started_at)
|
|
64
|
+
result = status.to_i.zero? ? "successful" : "failed"
|
|
65
|
+
|
|
66
|
+
synchronize do
|
|
67
|
+
flush_output_buffers(context)
|
|
68
|
+
@io.puts(" INFO [#{context.fetch(:id)}] Finished in #{format("%.3f seconds", runtime)} with exit status #{status} (#{result}).")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
def write_line(message)
|
|
74
|
+
synchronize { @io.puts(message) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def synchronize(&block)
|
|
78
|
+
@mutex.synchronize(&block)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def flush_complete_output_lines(context, key, redactor:)
|
|
82
|
+
buffer = @buffers.fetch(key)
|
|
83
|
+
output = +""
|
|
84
|
+
|
|
85
|
+
while (index = buffer.index("\n"))
|
|
86
|
+
output << buffer.slice!(0..index)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
@buffers[key] = buffer
|
|
90
|
+
write_output(context, output, redactor: redactor) unless output.empty?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def flush_output_buffers(context)
|
|
94
|
+
id = context.fetch(:id)
|
|
95
|
+
keys = @buffers.keys.select { |key_id, _stream| key_id == id }
|
|
96
|
+
|
|
97
|
+
keys.each do |key|
|
|
98
|
+
output = @buffers.delete(key)
|
|
99
|
+
next if output.to_s.empty?
|
|
100
|
+
|
|
101
|
+
write_output(context, output, redactor: context.fetch(:redactor))
|
|
102
|
+
@io.puts unless output.end_with?("\n")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def write_output(context, output, redactor:)
|
|
107
|
+
redactor.redact_string(output).each_line do |line|
|
|
108
|
+
@io.print(" DEBUG [#{context.fetch(:id)}] \t#{line}")
|
|
109
|
+
end
|
|
110
|
+
@io.flush if @io.respond_to?(:flush)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
26
113
|
|
|
27
114
|
class Command
|
|
28
115
|
class << self
|
|
116
|
+
attr_accessor :output
|
|
117
|
+
|
|
118
|
+
def with_output(output)
|
|
119
|
+
previous_output = self.output
|
|
120
|
+
self.output = output
|
|
121
|
+
yield
|
|
122
|
+
ensure
|
|
123
|
+
self.output = previous_output
|
|
124
|
+
end
|
|
125
|
+
|
|
29
126
|
def available?(name)
|
|
30
127
|
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
|
|
31
128
|
path = File.join(dir, name)
|
|
@@ -33,9 +130,23 @@ module KamalBackup
|
|
|
33
130
|
end
|
|
34
131
|
end
|
|
35
132
|
|
|
36
|
-
def capture(spec, input: nil, redactor:)
|
|
37
|
-
|
|
38
|
-
|
|
133
|
+
def capture(spec, input: nil, redactor:, log: true, log_output: true, tee_stdout: nil, tee_stderr: nil)
|
|
134
|
+
output = log ? self.output : nil
|
|
135
|
+
return capture_quietly(spec, input: input, redactor: redactor) unless output || tee_stdout || tee_stderr
|
|
136
|
+
|
|
137
|
+
context = output&.command_start(spec, redactor: redactor)
|
|
138
|
+
stdout, stderr, status = popen_capture(
|
|
139
|
+
spec,
|
|
140
|
+
input: input,
|
|
141
|
+
redactor: redactor,
|
|
142
|
+
output: output,
|
|
143
|
+
context: context,
|
|
144
|
+
log_output: log_output,
|
|
145
|
+
tee_stdout: tee_stdout,
|
|
146
|
+
tee_stderr: tee_stderr
|
|
147
|
+
)
|
|
148
|
+
output&.command_exit(context, status.exitstatus)
|
|
149
|
+
result = CommandResult.new(stdout: stdout, stderr: stderr, status: status.exitstatus, streamed: !!(tee_stdout || tee_stderr))
|
|
39
150
|
|
|
40
151
|
if status.success?
|
|
41
152
|
result
|
|
@@ -46,7 +157,72 @@ module KamalBackup
|
|
|
46
157
|
raise command_not_found(spec, e)
|
|
47
158
|
end
|
|
48
159
|
|
|
160
|
+
def collect_stream(io, command_output: self.output, context: nil, stream: :stdout, log_output: true, tee_io: nil, redactor: nil)
|
|
161
|
+
captured_output = +""
|
|
162
|
+
tee_buffer = +""
|
|
163
|
+
|
|
164
|
+
loop do
|
|
165
|
+
chunk = io.readpartial(16 * 1024)
|
|
166
|
+
captured_output << chunk
|
|
167
|
+
command_output&.command_output(context, stream, chunk, redactor: redactor) if log_output && context
|
|
168
|
+
tee_buffer = tee_stream(tee_io, redactor, tee_buffer, chunk) if tee_io
|
|
169
|
+
rescue EOFError
|
|
170
|
+
flush_tee_stream(tee_io, redactor, tee_buffer) if tee_io
|
|
171
|
+
break
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
captured_output
|
|
175
|
+
end
|
|
176
|
+
|
|
49
177
|
private
|
|
178
|
+
def tee_stream(io, redactor, buffer, chunk)
|
|
179
|
+
buffer << chunk
|
|
180
|
+
|
|
181
|
+
while (index = buffer.index("\n"))
|
|
182
|
+
io.print(redactor.redact_string(buffer.slice!(0..index)))
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
io.flush if io.respond_to?(:flush)
|
|
186
|
+
buffer
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def flush_tee_stream(io, redactor, buffer)
|
|
190
|
+
return if buffer.empty?
|
|
191
|
+
|
|
192
|
+
io.print(redactor.redact_string(buffer))
|
|
193
|
+
io.flush if io.respond_to?(:flush)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def capture_quietly(spec, input:, redactor:)
|
|
197
|
+
stdout, stderr, status = Open3.capture3(spec.env, *spec.argv, stdin_data: input)
|
|
198
|
+
result = CommandResult.new(stdout: stdout, stderr: stderr, status: status.exitstatus, streamed: false)
|
|
199
|
+
|
|
200
|
+
if status.success?
|
|
201
|
+
result
|
|
202
|
+
else
|
|
203
|
+
raise command_failure(spec, status.exitstatus, stdout, stderr, redactor)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def popen_capture(spec, input:, redactor:, output:, context:, log_output:, tee_stdout:, tee_stderr:)
|
|
208
|
+
Open3.popen3(spec.env, *spec.argv) do |stdin, stdout, stderr, wait_thread|
|
|
209
|
+
stdin_writer = Thread.new do
|
|
210
|
+
stdin.write(input) if input
|
|
211
|
+
ensure
|
|
212
|
+
stdin.close unless stdin.closed?
|
|
213
|
+
end
|
|
214
|
+
stdout_reader = Thread.new do
|
|
215
|
+
collect_stream(stdout, command_output: output, context: context, stream: :stdout, log_output: log_output, tee_io: tee_stdout, redactor: redactor)
|
|
216
|
+
end
|
|
217
|
+
stderr_reader = Thread.new do
|
|
218
|
+
collect_stream(stderr, command_output: output, context: context, stream: :stderr, log_output: log_output, tee_io: tee_stderr, redactor: redactor)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
stdin_writer.join
|
|
222
|
+
[stdout_reader.value, stderr_reader.value, wait_thread.value]
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
50
226
|
def command_failure(spec, status, stdout, stderr, redactor)
|
|
51
227
|
CommandError.new(
|
|
52
228
|
"command failed (#{status}): #{spec.display(redactor)}\n#{redactor.redact_string(stderr)}",
|
|
@@ -24,11 +24,11 @@ module KamalBackup
|
|
|
24
24
|
@redactor = redactor
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
def backup(restic
|
|
27
|
+
def backup(restic)
|
|
28
28
|
restic.backup_stream(
|
|
29
29
|
dump_command,
|
|
30
|
-
filename: database_filename
|
|
31
|
-
tags: backup_tags
|
|
30
|
+
filename: database_filename,
|
|
31
|
+
tags: backup_tags
|
|
32
32
|
)
|
|
33
33
|
end
|
|
34
34
|
|
|
@@ -41,14 +41,14 @@ module KamalBackup
|
|
|
41
41
|
restic.pipe_dump_to_command(snapshot, filename, scratch_restore_command(target))
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
-
def database_filename
|
|
44
|
+
def database_filename
|
|
45
45
|
app = config.app_name.gsub(/[^A-Za-z0-9_.-]+/, "-")
|
|
46
46
|
database = config.database_name.gsub(/[^A-Za-z0-9_.-]+/, "-")
|
|
47
|
-
"databases
|
|
47
|
+
"databases/#{app}/#{database}/#{adapter_name}.#{dump_extension}"
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
def backup_tags
|
|
51
|
-
["type:database", "database:#{config.database_name}", "adapter:#{adapter_name}"
|
|
50
|
+
def backup_tags
|
|
51
|
+
["type:database", "database:#{config.database_name}", "adapter:#{adapter_name}"]
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
def adapter_name
|
|
@@ -13,7 +13,7 @@ module KamalBackup
|
|
|
13
13
|
"sqlite3"
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
def backup(restic
|
|
16
|
+
def backup(restic)
|
|
17
17
|
source = sqlite_source
|
|
18
18
|
Tempfile.create(["kamal-backup-", ".sqlite3"]) do |tempfile|
|
|
19
19
|
tempfile.close
|
|
@@ -23,8 +23,8 @@ module KamalBackup
|
|
|
23
23
|
)
|
|
24
24
|
restic.backup_file(
|
|
25
25
|
tempfile.path,
|
|
26
|
-
filename: database_filename
|
|
27
|
-
tags: backup_tags
|
|
26
|
+
filename: database_filename,
|
|
27
|
+
tags: backup_tags
|
|
28
28
|
)
|
|
29
29
|
end
|
|
30
30
|
end
|
|
@@ -7,12 +7,14 @@ module KamalBackup
|
|
|
7
7
|
DEFAULT_CONFIG_FILE = "config/deploy.yml"
|
|
8
8
|
VERSION_LINE_PATTERN = /\A\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*\z/
|
|
9
9
|
|
|
10
|
-
def initialize(redactor:, config_file: nil, destination: nil, env: ENV, cwd: Dir.pwd)
|
|
10
|
+
def initialize(redactor:, config_file: nil, destination: nil, env: ENV, cwd: Dir.pwd, stdout: $stdout, stderr: $stderr)
|
|
11
11
|
@redactor = redactor
|
|
12
12
|
@config_file = config_file
|
|
13
13
|
@destination = destination
|
|
14
14
|
@env = env
|
|
15
15
|
@cwd = cwd
|
|
16
|
+
@stdout = stdout
|
|
17
|
+
@stderr = stderr
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
def accessory_name(preferred: nil)
|
|
@@ -50,8 +52,8 @@ module KamalBackup
|
|
|
50
52
|
accessory_secret_placeholders(accessory_name).merge(accessory_clear_env(accessory_name))
|
|
51
53
|
end
|
|
52
54
|
|
|
53
|
-
def execute_on_accessory(accessory_name:, command:)
|
|
54
|
-
capture_kamal(kamal_exec_argv(accessory_name, command))
|
|
55
|
+
def execute_on_accessory(accessory_name:, command:, stream: false)
|
|
56
|
+
capture_kamal(kamal_exec_argv(accessory_name, command), stream: stream)
|
|
55
57
|
end
|
|
56
58
|
|
|
57
59
|
def remote_version(accessory_name:)
|
|
@@ -212,13 +214,19 @@ module KamalBackup
|
|
|
212
214
|
argv
|
|
213
215
|
end
|
|
214
216
|
|
|
215
|
-
def capture_kamal(argv)
|
|
217
|
+
def capture_kamal(argv, stream: false)
|
|
216
218
|
spec = CommandSpec.new(argv: argv)
|
|
219
|
+
options = {
|
|
220
|
+
redactor: @redactor,
|
|
221
|
+
log_output: false,
|
|
222
|
+
tee_stdout: stream ? @stdout : nil,
|
|
223
|
+
tee_stderr: stream ? @stderr : nil
|
|
224
|
+
}
|
|
217
225
|
|
|
218
226
|
if defined?(Bundler)
|
|
219
|
-
Bundler.with_unbundled_env { Command.capture(spec,
|
|
227
|
+
Bundler.with_unbundled_env { Command.capture(spec, **options) }
|
|
220
228
|
else
|
|
221
|
-
Command.capture(spec,
|
|
229
|
+
Command.capture(spec, **options)
|
|
222
230
|
end
|
|
223
231
|
end
|
|
224
232
|
|
data/lib/kamal_backup/restic.rb
CHANGED
|
@@ -16,7 +16,7 @@ module KamalBackup
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def ensure_repository
|
|
19
|
-
run(%w[snapshots --json])
|
|
19
|
+
run(%w[snapshots --json], log_output: false)
|
|
20
20
|
rescue CommandError => e
|
|
21
21
|
if config.restic_init_if_missing?
|
|
22
22
|
log("restic repository not ready, running restic init")
|
|
@@ -28,7 +28,7 @@ module KamalBackup
|
|
|
28
28
|
|
|
29
29
|
def backup_stream(command, filename:, tags:)
|
|
30
30
|
restic_command = CommandSpec.new(
|
|
31
|
-
argv: ["restic", "backup"
|
|
31
|
+
argv: ["restic", "backup"] + host_args + ["--stdin", "--stdin-filename", filename] + tag_args(common_tags + tags),
|
|
32
32
|
env: restic_env
|
|
33
33
|
)
|
|
34
34
|
log("backing up stream as #{filename}")
|
|
@@ -37,20 +37,23 @@ module KamalBackup
|
|
|
37
37
|
|
|
38
38
|
def backup_file(path, filename:, tags:)
|
|
39
39
|
command = CommandSpec.new(
|
|
40
|
-
argv: ["restic", "backup"
|
|
40
|
+
argv: ["restic", "backup"] + host_args + ["--stdin", "--stdin-filename", filename] + tag_args(common_tags + tags),
|
|
41
41
|
env: restic_env
|
|
42
42
|
)
|
|
43
43
|
log("backing up file content as #{filename}")
|
|
44
44
|
|
|
45
45
|
File.open(path, "rb") do |file|
|
|
46
|
+
output = Command.output
|
|
47
|
+
context = output&.command_start(command, redactor: redactor)
|
|
46
48
|
Open3.popen3(command.env, *command.argv) do |stdin, stdout, stderr, wait_thread|
|
|
47
|
-
stdout_reader = Thread.new { stdout
|
|
48
|
-
stderr_reader = Thread.new { stderr
|
|
49
|
+
stdout_reader = Thread.new { Command.collect_stream(stdout, command_output: output, context: context, stream: :stdout, redactor: redactor) }
|
|
50
|
+
stderr_reader = Thread.new { Command.collect_stream(stderr, command_output: output, context: context, stream: :stderr, redactor: redactor) }
|
|
49
51
|
IO.copy_stream(file, stdin)
|
|
50
52
|
stdin.close
|
|
51
53
|
out = stdout_reader.value
|
|
52
54
|
err = stderr_reader.value
|
|
53
55
|
status = wait_thread.value
|
|
56
|
+
output&.command_exit(context, status.exitstatus)
|
|
54
57
|
raise_command_error(command, status, out, err) unless status.success?
|
|
55
58
|
|
|
56
59
|
CommandResult.new(stdout: out, stderr: err, status: status.exitstatus)
|
|
@@ -66,7 +69,7 @@ module KamalBackup
|
|
|
66
69
|
if paths.any?
|
|
67
70
|
path_tags = paths.map { |path| "path:#{config.backup_path_label(path)}" }
|
|
68
71
|
log("backing up #{paths.size} file path(s): #{paths.join(", ")}")
|
|
69
|
-
run(["backup"] + paths + tag_args(common_tags + tags + path_tags))
|
|
72
|
+
run(["backup"] + host_args + paths + tag_args(common_tags + tags + path_tags))
|
|
70
73
|
end
|
|
71
74
|
end
|
|
72
75
|
|
|
@@ -99,7 +102,7 @@ module KamalBackup
|
|
|
99
102
|
end
|
|
100
103
|
|
|
101
104
|
def snapshots_json(tags: common_tags)
|
|
102
|
-
output = run(["snapshots", "--json"] + filter_tag_args(tags)).stdout
|
|
105
|
+
output = run(["snapshots", "--json"] + filter_tag_args(tags), log_output: false).stdout
|
|
103
106
|
snapshots = JSON.parse(output)
|
|
104
107
|
required_tags = tags.compact
|
|
105
108
|
snapshots.select do |snapshot|
|
|
@@ -116,7 +119,7 @@ module KamalBackup
|
|
|
116
119
|
end
|
|
117
120
|
|
|
118
121
|
def ls_json(snapshot)
|
|
119
|
-
output = run(["ls", "--json", snapshot]).stdout
|
|
122
|
+
output = run(["ls", "--json", snapshot], log_output: false).stdout
|
|
120
123
|
output.lines.filter_map do |line|
|
|
121
124
|
JSON.parse(line)
|
|
122
125
|
rescue JSON::ParserError
|
|
@@ -128,13 +131,15 @@ module KamalBackup
|
|
|
128
131
|
legacy_prefix = "databases/#{config.app_name}/#{adapter}/"
|
|
129
132
|
app = config.app_name.gsub(/[^A-Za-z0-9_.-]+/, "-")
|
|
130
133
|
database = database_name.to_s.gsub(/[^A-Za-z0-9_.-]+/, "-")
|
|
134
|
+
stable_prefix = database.empty? ? nil : "databases/#{app}/#{database}/#{adapter}."
|
|
131
135
|
flat_prefix = "databases-#{app}-#{adapter}-"
|
|
132
136
|
named_flat_prefix = database.empty? ? nil : "databases-#{app}-#{database}-#{adapter}-"
|
|
133
137
|
ls_json(snapshot).find do |entry|
|
|
134
138
|
next false unless entry["type"] == "file"
|
|
135
139
|
|
|
136
140
|
normalized = entry["path"].to_s.sub(%r{\A/+}, "")
|
|
137
|
-
normalized.start_with?(
|
|
141
|
+
(stable_prefix && normalized.start_with?(stable_prefix)) ||
|
|
142
|
+
normalized.start_with?(legacy_prefix) ||
|
|
138
143
|
File.basename(normalized).start_with?(flat_prefix) ||
|
|
139
144
|
(named_flat_prefix && File.basename(normalized).start_with?(named_flat_prefix))
|
|
140
145
|
end&.fetch("path")
|
|
@@ -151,12 +156,15 @@ module KamalBackup
|
|
|
151
156
|
FileUtils.mkdir_p(File.dirname(target_path))
|
|
152
157
|
temp_path = "#{target_path}.kamal-backup-#{$$}.tmp"
|
|
153
158
|
|
|
159
|
+
output = Command.output
|
|
160
|
+
context = output&.command_start(command, redactor: redactor)
|
|
154
161
|
Open3.popen3(command.env, *command.argv) do |stdin, stdout, stderr, wait_thread|
|
|
155
162
|
stdin.close
|
|
156
|
-
stderr_reader = Thread.new { stderr
|
|
163
|
+
stderr_reader = Thread.new { Command.collect_stream(stderr, command_output: output, context: context, stream: :stderr, redactor: redactor) }
|
|
157
164
|
File.open(temp_path, "wb") { |file| IO.copy_stream(stdout, file) }
|
|
158
165
|
err = stderr_reader.value
|
|
159
166
|
status = wait_thread.value
|
|
167
|
+
output&.command_exit(context, status.exitstatus)
|
|
160
168
|
raise_command_error(command, status, "", err) unless status.success?
|
|
161
169
|
end
|
|
162
170
|
File.rename(temp_path, target_path)
|
|
@@ -174,8 +182,12 @@ module KamalBackup
|
|
|
174
182
|
run(["restore", snapshot, "--target", target])
|
|
175
183
|
end
|
|
176
184
|
|
|
177
|
-
def run(args)
|
|
178
|
-
Command.capture(
|
|
185
|
+
def run(args, log_output: true)
|
|
186
|
+
Command.capture(
|
|
187
|
+
CommandSpec.new(argv: ["restic"] + args, env: restic_env),
|
|
188
|
+
redactor: redactor,
|
|
189
|
+
log_output: log_output
|
|
190
|
+
)
|
|
179
191
|
end
|
|
180
192
|
|
|
181
193
|
def common_tags
|
|
@@ -213,6 +225,19 @@ module KamalBackup
|
|
|
213
225
|
tags.compact.each_with_object([]) { |tag, args| args.concat(["--tag", tag]) }
|
|
214
226
|
end
|
|
215
227
|
|
|
228
|
+
def host_args
|
|
229
|
+
["--host", restic_host]
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def restic_host
|
|
233
|
+
normalize_restic_host([config.app_name, config.accessory_name || "backup"].compact.join("-"))
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def normalize_restic_host(value)
|
|
237
|
+
normalized = value.to_s.gsub(/[^A-Za-z0-9_.-]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
238
|
+
normalized.empty? ? "kamal-backup" : normalized
|
|
239
|
+
end
|
|
240
|
+
|
|
216
241
|
def filter_tag_args(tags)
|
|
217
242
|
tags = tags.compact
|
|
218
243
|
tags.empty? ? [] : ["--tag", tags.join(",")]
|
|
@@ -225,13 +250,16 @@ module KamalBackup
|
|
|
225
250
|
end
|
|
226
251
|
|
|
227
252
|
def pipe_commands(producer, consumer, producer_label:, consumer_label:)
|
|
253
|
+
output = Command.output
|
|
254
|
+
producer_context = output&.command_start(producer, redactor: redactor)
|
|
228
255
|
Open3.popen3(producer.env, *producer.argv) do |producer_stdin, producer_stdout, producer_stderr, producer_wait|
|
|
229
256
|
producer_stdin.close
|
|
230
257
|
|
|
258
|
+
consumer_context = output&.command_start(consumer, redactor: redactor)
|
|
231
259
|
Open3.popen3(consumer.env, *consumer.argv) do |consumer_stdin, consumer_stdout, consumer_stderr, consumer_wait|
|
|
232
|
-
producer_err_reader = Thread.new { producer_stderr
|
|
233
|
-
consumer_out_reader = Thread.new { consumer_stdout
|
|
234
|
-
consumer_err_reader = Thread.new { consumer_stderr
|
|
260
|
+
producer_err_reader = Thread.new { Command.collect_stream(producer_stderr, command_output: output, context: producer_context, stream: :stderr, redactor: redactor) }
|
|
261
|
+
consumer_out_reader = Thread.new { Command.collect_stream(consumer_stdout, command_output: output, context: consumer_context, stream: :stdout, redactor: redactor) }
|
|
262
|
+
consumer_err_reader = Thread.new { Command.collect_stream(consumer_stderr, command_output: output, context: consumer_context, stream: :stderr, redactor: redactor) }
|
|
235
263
|
|
|
236
264
|
copy_error = nil
|
|
237
265
|
copy_thread = Thread.new do
|
|
@@ -245,6 +273,8 @@ module KamalBackup
|
|
|
245
273
|
copy_thread.join
|
|
246
274
|
producer_status = producer_wait.value
|
|
247
275
|
consumer_status = consumer_wait.value
|
|
276
|
+
output&.command_exit(producer_context, producer_status.exitstatus)
|
|
277
|
+
output&.command_exit(consumer_context, consumer_status.exitstatus)
|
|
248
278
|
|
|
249
279
|
producer_err = producer_err_reader.value
|
|
250
280
|
consumer_out = consumer_out_reader.value
|
|
@@ -287,7 +317,11 @@ module KamalBackup
|
|
|
287
317
|
end
|
|
288
318
|
|
|
289
319
|
def log(message)
|
|
290
|
-
|
|
320
|
+
if Command.output
|
|
321
|
+
Command.output.info(message, redactor: redactor)
|
|
322
|
+
else
|
|
323
|
+
$stdout.puts("[kamal-backup] #{redactor.redact_string(message)}")
|
|
324
|
+
end
|
|
291
325
|
end
|
|
292
326
|
end
|
|
293
327
|
end
|
data/lib/kamal_backup/version.rb
CHANGED