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