kamal-backup 0.3.0.beta21 → 0.3.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.
@@ -1,13 +1,34 @@
1
- require "shellwords"
2
- require "yaml"
3
- require_relative "command"
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+ require 'yaml'
5
+ require_relative 'command'
6
+ require_relative 'yaml_access'
4
7
 
5
8
  module KamalBackup
6
9
  class KamalBridge
7
- DEFAULT_CONFIG_FILE = "config/deploy.yml"
10
+ include YamlAccess
11
+
12
+ DEFAULT_CONFIG_FILE = 'config/deploy.yml'
8
13
  VERSION_LINE_PATTERN = /\A\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*\z/
9
14
 
10
- def initialize(redactor:, config_file: nil, destination: nil, env: ENV, cwd: Dir.pwd, stdout: $stdout, stderr: $stderr)
15
+ class FilteringIO
16
+ def initialize(io, &reject)
17
+ @io = io
18
+ @reject = reject
19
+ end
20
+
21
+ def print(output)
22
+ @io.print(output) unless @reject.call(output.to_s)
23
+ end
24
+
25
+ def flush
26
+ @io.flush if @io.respond_to?(:flush)
27
+ end
28
+ end
29
+
30
+ def initialize(redactor:, config_file: nil, destination: nil, env: ENV, cwd: Dir.pwd, stdout: $stdout,
31
+ stderr: $stderr)
11
32
  @redactor = redactor
12
33
  @config_file = config_file
13
34
  @destination = destination
@@ -24,16 +45,17 @@ module KamalBackup
24
45
  end
25
46
 
26
47
  matching = accessories.select do |_name, accessory|
27
- fetch(accessory, :image).to_s.include?("kamal-backup")
48
+ fetch(accessory, :image).to_s.include?('kamal-backup')
28
49
  end
29
50
 
30
51
  if matching.size == 1
31
52
  matching.keys.first.to_s
32
- elsif accessories.key?("backup") || accessories.key?(:backup)
33
- "backup"
53
+ elsif accessories.key?('backup') || accessories.key?(:backup)
54
+ 'backup'
34
55
  else
35
56
  names = accessories.keys.map(&:to_s).sort
36
- raise ConfigurationError, "could not infer the backup accessory from #{names.join(', ')}; set accessory in config/kamal-backup.yml"
57
+ raise ConfigurationError,
58
+ "could not infer the backup accessory from #{names.join(', ')}; set accessory in config/kamal-backup.yml"
37
59
  end
38
60
  end
39
61
 
@@ -41,10 +63,10 @@ module KamalBackup
41
63
  clear_env = accessory_clear_env(accessory_name)
42
64
 
43
65
  {}.tap do |defaults|
44
- defaults["APP_NAME"] = clear_env["APP_NAME"] if clear_env["APP_NAME"]
45
- defaults["DATABASE_ADAPTER"] = clear_env["DATABASE_ADAPTER"] if clear_env["DATABASE_ADAPTER"]
46
- defaults["RESTIC_REPOSITORY"] = clear_env["RESTIC_REPOSITORY"] if clear_env["RESTIC_REPOSITORY"]
47
- defaults["LOCAL_RESTORE_SOURCE_PATHS"] = clear_env["BACKUP_PATHS"] if clear_env["BACKUP_PATHS"]
66
+ defaults['APP_NAME'] = clear_env['APP_NAME'] if clear_env['APP_NAME']
67
+ defaults['DATABASE_ADAPTER'] = clear_env['DATABASE_ADAPTER'] if clear_env['DATABASE_ADAPTER']
68
+ defaults['RESTIC_REPOSITORY'] = clear_env['RESTIC_REPOSITORY'] if clear_env['RESTIC_REPOSITORY']
69
+ defaults['LOCAL_RESTORE_SOURCE_PATHS'] = clear_env['BACKUP_PATHS'] if clear_env['BACKUP_PATHS']
48
70
  end
49
71
  end
50
72
 
@@ -53,312 +75,306 @@ module KamalBackup
53
75
  end
54
76
 
55
77
  def execute_on_accessory(accessory_name:, command:, stream: false)
78
+ command_argv = remote_command_argv(command)
79
+
56
80
  if stream && (target = live_accessory_target(accessory_name))
57
- execute_on_accessory_live(accessory_name: accessory_name, command: command, target: target)
81
+ execute_on_accessory_live(accessory_name: accessory_name, command_argv: command_argv, target: target)
58
82
  else
59
- capture_kamal(kamal_exec_argv(accessory_name, command), stream: stream)
83
+ capture_kamal(kamal_exec_argv(accessory_name, command_argv), stream: stream)
60
84
  end
61
85
  end
62
86
 
63
87
  def remote_version(accessory_name:)
64
- result = execute_on_accessory(accessory_name: accessory_name, command: "kamal-backup version")
88
+ result = execute_on_accessory(accessory_name: accessory_name, command: %w[kamal-backup version])
65
89
  version = parse_version_line(result.stdout)
66
90
 
67
- if version.empty?
68
- raise ConfigurationError, "could not determine remote kamal-backup version from accessory #{accessory_name}"
69
- else
70
- version
71
- end
91
+ raise ConfigurationError, "could not determine remote kamal-backup version from accessory #{accessory_name}" if version.empty?
92
+
93
+ version
72
94
  end
73
95
 
74
96
  private
75
- def config
76
- @config ||= begin
77
- result = capture_kamal(kamal_config_argv)
78
- load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load
79
- YAML.public_send(load_method, result.stdout)
80
- end
81
- end
82
97
 
83
- def accessories
84
- fetch(config, :accessories) || {}
98
+ def config
99
+ @config ||= begin
100
+ result = capture_kamal(kamal_config_argv)
101
+ load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load
102
+ YAML.public_send(load_method, result.stdout)
85
103
  end
104
+ end
86
105
 
87
- def accessory_clear_env(accessory_name)
88
- normalize_env(fetch(accessory_env(accessory_name), :clear) || {})
89
- end
106
+ def accessories
107
+ fetch(config, :accessories) || {}
108
+ end
90
109
 
91
- def accessory_secret_placeholders(accessory_name)
92
- normalize_secret_env(fetch(accessory_env(accessory_name), :secret))
93
- end
110
+ def accessory_clear_env(accessory_name)
111
+ normalize_env(fetch(accessory_env(accessory_name), :clear) || {})
112
+ end
94
113
 
95
- def accessory_env(accessory_name)
96
- fetch(accessory(accessory_name), :env) || {}
97
- end
114
+ def accessory_secret_placeholders(accessory_name)
115
+ normalize_secret_env(fetch(accessory_env(accessory_name), :secret))
116
+ end
98
117
 
99
- def accessory(accessory_name)
100
- accessories.fetch(accessory_name) do
101
- accessories.fetch(accessory_name.to_sym) do
102
- raise ConfigurationError, "accessory #{accessory_name.inspect} is not defined in #{config_file || DEFAULT_CONFIG_FILE}"
103
- end
118
+ def accessory_env(accessory_name)
119
+ fetch(accessory(accessory_name), :env) || {}
120
+ end
121
+
122
+ def accessory(accessory_name)
123
+ accessories.fetch(accessory_name) do
124
+ accessories.fetch(accessory_name.to_sym) do
125
+ raise ConfigurationError,
126
+ "accessory #{accessory_name.inspect} is not defined in #{@config_file || DEFAULT_CONFIG_FILE}"
104
127
  end
105
128
  end
129
+ end
106
130
 
107
- def normalize_env(values)
108
- values.each_with_object({}) do |(key, value), env|
109
- env[key.to_s] = value.to_s
110
- end
131
+ def normalize_env(values)
132
+ values.each_with_object({}) do |(key, value), env|
133
+ env[key.to_s] = value.to_s
111
134
  end
135
+ end
112
136
 
113
- def normalize_secret_env(values)
114
- case values
115
- when Hash
116
- values.each_with_object({}) do |(key, secret_key), env|
117
- add_resolved_secret(env, target: key, source: secret_key)
118
- end
119
- when Array
120
- values.each_with_object({}) do |entry, env|
121
- target, source = parse_secret_entry(entry)
122
- add_resolved_secret(env, target: target, source: source)
123
- end
124
- when String, Symbol
125
- {}.tap do |env|
126
- target, source = parse_secret_entry(values)
127
- add_resolved_secret(env, target: target, source: source)
128
- end
129
- else
130
- {}
137
+ def normalize_secret_env(values)
138
+ case values
139
+ when Hash
140
+ values.each_with_object({}) do |(key, secret_key), env|
141
+ add_resolved_secret(env, target: key, source: secret_key)
142
+ end
143
+ when Array
144
+ values.each_with_object({}) do |entry, env|
145
+ target, source = parse_secret_entry(entry)
146
+ add_resolved_secret(env, target: target, source: source)
147
+ end
148
+ when String, Symbol
149
+ {}.tap do |env|
150
+ target, source = parse_secret_entry(values)
151
+ add_resolved_secret(env, target: target, source: source)
131
152
  end
153
+ else
154
+ {}
132
155
  end
156
+ end
133
157
 
134
- def parse_secret_entry(entry)
135
- target, source = entry.to_s.split(":", 2)
136
- [ target, source || target ]
137
- end
158
+ def parse_secret_entry(entry)
159
+ target, source = entry.to_s.split(':', 2)
160
+ [target, source || target]
161
+ end
138
162
 
139
- def add_resolved_secret(env, target:, source:)
140
- if value = resolved_secret(source)
141
- env[target.to_s] = value
142
- end
163
+ def add_resolved_secret(env, target:, source:)
164
+ if (value = resolved_secret(source))
165
+ env[target.to_s] = value
143
166
  end
167
+ end
144
168
 
145
- def resolved_secret(key)
146
- raw = resolved_secrets[key.to_s] || @env[key.to_s]
147
- value = raw.to_s.strip
148
- value.empty? ? nil : value
149
- end
169
+ def resolved_secret(key)
170
+ raw = resolved_secrets[key.to_s] || @env[key.to_s]
171
+ value = raw.to_s.strip
172
+ value.empty? ? nil : value
173
+ end
150
174
 
151
- def resolved_secrets
152
- @resolved_secrets ||= parse_secret_output(capture_kamal(kamal_secrets_print_argv).stdout)
153
- end
175
+ def resolved_secrets
176
+ @resolved_secrets ||= parse_secret_output(capture_kamal(kamal_secrets_print_argv).stdout)
177
+ end
154
178
 
155
- def parse_secret_output(output)
156
- output.to_s.lines.each_with_object({}) do |line, secrets|
157
- tokens = Shellwords.split(line.chomp)
158
- tokens.shift if tokens.first == "export"
179
+ def parse_secret_output(output)
180
+ output.to_s.lines.each_with_object({}) do |line, secrets|
181
+ tokens = Shellwords.split(line.chomp)
182
+ tokens.shift if tokens.first == 'export'
159
183
 
160
- tokens.each do |assignment|
161
- key, value = assignment.split("=", 2)
162
- next if key.to_s.empty? || value.nil?
184
+ tokens.each do |assignment|
185
+ key, value = assignment.split('=', 2)
186
+ next if key.to_s.empty? || value.nil?
163
187
 
164
- secrets[key] = value.to_s
165
- end
188
+ secrets[key] = value.to_s
166
189
  end
167
190
  end
191
+ end
168
192
 
169
- def fetch(hash, key)
170
- hash[key] || hash[key.to_s] || hash[key.to_sym]
171
- end
172
-
173
- def kamal_config_argv
174
- [
175
- *kamal_command,
176
- "config",
177
- *kamal_option_argv,
178
- "--version",
179
- "latest"
180
- ]
181
- end
193
+ def kamal_config_argv
194
+ [
195
+ *kamal_command,
196
+ 'config',
197
+ *kamal_option_argv,
198
+ '--version',
199
+ 'latest'
200
+ ]
201
+ end
182
202
 
183
- def kamal_exec_argv(accessory_name, command, interactive: false)
184
- [
185
- *kamal_command,
186
- "accessory",
187
- "exec",
188
- *kamal_option_argv,
189
- *(["--interactive"] if interactive),
190
- "--reuse",
191
- accessory_name,
192
- command
193
- ]
194
- end
203
+ def kamal_exec_argv(accessory_name, command, interactive: false)
204
+ [
205
+ *kamal_command,
206
+ 'accessory',
207
+ 'exec',
208
+ *kamal_option_argv,
209
+ *(['--interactive'] if interactive),
210
+ '--reuse',
211
+ accessory_name,
212
+ *kamal_remote_command_argv(command)
213
+ ]
214
+ end
195
215
 
196
- def kamal_secrets_print_argv
197
- [
198
- *kamal_command,
199
- "secrets",
200
- "print",
201
- *kamal_option_argv
202
- ]
203
- end
216
+ def kamal_secrets_print_argv
217
+ [
218
+ *kamal_command,
219
+ 'secrets',
220
+ 'print',
221
+ *kamal_option_argv
222
+ ]
223
+ end
204
224
 
205
- def kamal_command
206
- if File.executable?(File.join(@cwd, "bin", "kamal"))
207
- [ "bin/kamal" ]
208
- elsif File.file?(File.join(@cwd, "Gemfile"))
209
- [ "bundle", "exec", "kamal" ]
210
- else
211
- [ "kamal" ]
212
- end
225
+ def kamal_command
226
+ if File.executable?(File.join(@cwd, 'bin', 'kamal'))
227
+ ['bin/kamal']
228
+ elsif File.file?(File.join(@cwd, 'Gemfile'))
229
+ %w[bundle exec kamal]
230
+ else
231
+ ['kamal']
213
232
  end
233
+ end
214
234
 
215
- def kamal_option_argv
216
- argv = []
217
- argv.concat(["-c", @config_file]) if @config_file
218
- argv.concat(["-d", @destination]) if @destination
219
- argv
220
- end
235
+ def kamal_option_argv
236
+ argv = []
237
+ argv.concat(['-c', @config_file]) if @config_file
238
+ argv.concat(['-d', @destination]) if @destination
239
+ argv
240
+ end
221
241
 
222
- def capture_kamal(argv, stream: false, log: !stream, stdout: @stdout, stderr: @stderr, pty: false)
223
- spec = CommandSpec.new(argv: argv, env: kamal_stream_env(stream))
224
- options = {
225
- redactor: @redactor,
226
- log: log,
227
- log_output: false,
228
- tee_stdout: stream ? stdout : nil,
229
- tee_stderr: stream ? stderr : nil
230
- }
231
-
232
- if defined?(Bundler)
233
- Bundler.with_unbundled_env { capture_command(spec, options, pty: pty) }
234
- else
235
- capture_command(spec, options, pty: pty)
236
- end
242
+ def capture_kamal(argv, stream: false, log: !stream, stdout: @stdout, stderr: @stderr, pty: false)
243
+ spec = CommandSpec.new(argv: argv, env: kamal_stream_env(stream))
244
+ options = {
245
+ redactor: @redactor,
246
+ log: log,
247
+ log_output: false,
248
+ tee_stdout: stream ? stdout : nil,
249
+ tee_stderr: stream ? stderr : nil
250
+ }
251
+
252
+ if defined?(Bundler)
253
+ Bundler.with_unbundled_env { capture_command(spec, options, pty: pty) }
254
+ else
255
+ capture_command(spec, options, pty: pty)
237
256
  end
257
+ end
238
258
 
239
- def capture_command(spec, options, pty:)
240
- if pty
241
- Command.capture_pty(spec, redactor: options.fetch(:redactor), tee_stdout: options[:tee_stdout])
242
- else
243
- Command.capture(spec, **options)
244
- end
259
+ def capture_command(spec, options, pty:)
260
+ if pty
261
+ Command.capture_pty(spec, redactor: options.fetch(:redactor), tee_stdout: options[:tee_stdout])
262
+ else
263
+ Command.capture(spec, **options)
245
264
  end
265
+ end
246
266
 
247
- def execute_on_accessory_live(accessory_name:, command:, target:)
248
- @stdout.puts("Launching command from existing container...")
249
-
250
- spec = CommandSpec.new(
251
- argv: ["docker", "exec", target.fetch(:service_name), *Shellwords.split(command)],
252
- host: target.fetch(:host)
253
- )
254
- context = Command.output&.command_start(spec, redactor: @redactor)
255
-
256
- result = capture_kamal(
257
- kamal_exec_argv(accessory_name, command, interactive: true),
258
- stream: true,
259
- log: false,
260
- stdout: filtered_interactive_stdout,
261
- pty: true
262
- )
263
- Command.output&.command_exit(context, result.status) if context
264
- result
265
- rescue CommandError => e
266
- Command.output&.command_exit(context, e.status || 1) if context
267
- raise
268
- end
267
+ def execute_on_accessory_live(accessory_name:, command_argv:, target:)
268
+ @stdout.puts('Launching command from existing container...')
269
+
270
+ spec = CommandSpec.new(
271
+ argv: ['docker', 'exec', target.fetch(:service_name), *command_argv],
272
+ host: target.fetch(:host)
273
+ )
274
+ context = Command.output&.command_start(spec, redactor: @redactor)
275
+
276
+ result = capture_kamal(
277
+ kamal_exec_argv(accessory_name, command_argv, interactive: true),
278
+ stream: true,
279
+ log: false,
280
+ stdout: filtered_interactive_stdout,
281
+ pty: true
282
+ )
283
+ Command.output&.command_exit(context, result.status) if context
284
+ result
285
+ rescue CommandError => e
286
+ Command.output&.command_exit(context, e.status || 1) if context
287
+ raise
288
+ end
269
289
 
270
- def filtered_interactive_stdout
271
- FilteringIO.new(@stdout) do |output|
272
- stripped = output.to_s.gsub(/\e\[[0-9;]*m/, "").delete("\r").strip
273
- stripped == "Launching interactive command via SSH from existing container..." ||
274
- stripped.match?(/\AConnection to .+ closed\.\z/)
275
- end
290
+ def filtered_interactive_stdout
291
+ FilteringIO.new(@stdout) do |output|
292
+ stripped = output.to_s.gsub(/\e\[[0-9;]*m/, '').delete("\r").strip
293
+ stripped == 'Launching interactive command via SSH from existing container...' ||
294
+ stripped.match?(/\AConnection to .+ closed\.\z/)
276
295
  end
296
+ end
277
297
 
278
- def live_accessory_target(accessory_name)
279
- return unless defined?(@config)
298
+ def live_accessory_target(accessory_name)
299
+ return unless defined?(@config)
280
300
 
281
- accessory_config = accessory(accessory_name)
282
- host = single_accessory_host(accessory_config)
283
- service_name = fetch(accessory_config, :service) || default_accessory_service_name(accessory_name)
301
+ accessory_config = accessory(accessory_name)
302
+ host = single_accessory_host(accessory_config)
303
+ service_name = fetch(accessory_config, :service) || default_accessory_service_name(accessory_name)
284
304
 
285
- { host: host, service_name: service_name } if host && service_name
286
- rescue ConfigurationError, KeyError, NoMethodError, TypeError
287
- nil
288
- end
305
+ { host: host, service_name: service_name } if host && service_name
306
+ rescue ConfigurationError, KeyError, NoMethodError, TypeError
307
+ nil
308
+ end
289
309
 
290
- def single_accessory_host(accessory_config)
291
- hosts = if host = fetch(accessory_config, :host)
292
- normalized_hosts(host)
293
- else
294
- normalized_hosts(fetch(accessory_config, :hosts))
295
- end
310
+ def single_accessory_host(accessory_config)
311
+ hosts = if (host = fetch(accessory_config, :host))
312
+ normalized_hosts(host)
313
+ else
314
+ normalized_hosts(fetch(accessory_config, :hosts))
315
+ end
296
316
 
297
- return hosts.first if hosts.size == 1
298
- return if hosts.any?
317
+ return hosts.first if hosts.size == 1
318
+ return if hosts.any?
299
319
 
300
- all_hosts = normalized_hosts(fetch(config, :hosts))
301
- all_hosts.first if all_hosts.size == 1
302
- end
320
+ all_hosts = normalized_hosts(fetch(config, :hosts))
321
+ all_hosts.first if all_hosts.size == 1
322
+ end
303
323
 
304
- def normalized_hosts(value)
305
- case value
306
- when nil
307
- []
308
- when Array
309
- value.map(&:to_s).reject(&:empty?)
310
- else
311
- [value.to_s].reject(&:empty?)
312
- end
324
+ def normalized_hosts(value)
325
+ case value
326
+ when nil
327
+ []
328
+ when Array
329
+ value.map(&:to_s).reject(&:empty?)
330
+ else
331
+ [value.to_s].reject(&:empty?)
313
332
  end
333
+ end
314
334
 
315
- def default_accessory_service_name(accessory_name)
316
- service = fetch(config, :service).to_s
317
- service = service_from_rendered_config if service.empty?
318
- "#{service}-#{accessory_name}" unless service.empty?
319
- end
335
+ def default_accessory_service_name(accessory_name)
336
+ service = fetch(config, :service).to_s
337
+ service = service_from_rendered_config if service.empty?
338
+ "#{service}-#{accessory_name}" unless service.empty?
339
+ end
320
340
 
321
- def service_from_rendered_config
322
- service_with_version = fetch(config, :service_with_version).to_s
323
- version = fetch(config, :version).to_s
324
- suffix = "-#{version}"
341
+ def service_from_rendered_config
342
+ service_with_version = fetch(config, :service_with_version).to_s
343
+ version = fetch(config, :version).to_s
344
+ suffix = "-#{version}"
325
345
 
326
- if !service_with_version.empty? && !version.empty? && service_with_version.end_with?(suffix)
327
- service_with_version.delete_suffix(suffix)
328
- end
329
- end
346
+ return unless !service_with_version.empty? && !version.empty? && service_with_version.end_with?(suffix)
330
347
 
331
- class FilteringIO
332
- def initialize(io, &reject)
333
- @io = io
334
- @reject = reject
335
- end
348
+ service_with_version.delete_suffix(suffix)
349
+ end
336
350
 
337
- def print(output)
338
- @io.print(output) unless @reject.call(output.to_s)
339
- end
351
+ def kamal_stream_env(stream)
352
+ return {} unless stream
340
353
 
341
- def flush
342
- @io.flush if @io.respond_to?(:flush)
343
- end
354
+ if @env['SSHKIT_COLOR'].to_s.empty?
355
+ stream_color? ? { 'SSHKIT_COLOR' => '1' } : {}
356
+ else
357
+ { 'SSHKIT_COLOR' => @env['SSHKIT_COLOR'] }
344
358
  end
359
+ end
345
360
 
346
- def kamal_stream_env(stream)
347
- return {} unless stream
361
+ def stream_color?
362
+ [@stdout, @stderr].any? { |io| io.respond_to?(:tty?) && io.tty? }
363
+ end
348
364
 
349
- if @env["SSHKIT_COLOR"].to_s.empty?
350
- stream_color? ? { "SSHKIT_COLOR" => "1" } : {}
351
- else
352
- { "SSHKIT_COLOR" => @env["SSHKIT_COLOR"] }
353
- end
354
- end
365
+ def remote_command_argv(command)
366
+ argv = command.is_a?(String) ? Shellwords.split(command) : Array(command).compact.map(&:to_s)
367
+ raise ArgumentError, 'remote command cannot be empty' if argv.empty?
355
368
 
356
- def stream_color?
357
- [@stdout, @stderr].any? { |io| io.respond_to?(:tty?) && io.tty? }
358
- end
369
+ argv
370
+ end
359
371
 
360
- def parse_version_line(output)
361
- output.to_s.lines.map(&:strip).reverse.find { |line| line.match?(VERSION_LINE_PATTERN) }.to_s
362
- end
372
+ def kamal_remote_command_argv(command)
373
+ remote_command_argv(command).map { |arg| Shellwords.escape(arg) }
374
+ end
375
+
376
+ def parse_version_line(output)
377
+ output.to_s.lines.map(&:strip).reverse.find { |line| line.match?(VERSION_LINE_PATTERN) }.to_s
378
+ end
363
379
  end
364
380
  end