kamal 2.7.0 → 2.11.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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/kamal/cli/accessory.rb +27 -7
  4. data/lib/kamal/cli/alias/command.rb +2 -2
  5. data/lib/kamal/cli/app/boot.rb +1 -1
  6. data/lib/kamal/cli/app.rb +74 -115
  7. data/lib/kamal/cli/base.rb +19 -6
  8. data/lib/kamal/cli/build/clone.rb +0 -2
  9. data/lib/kamal/cli/build/port_forwarding.rb +66 -0
  10. data/lib/kamal/cli/build.rb +70 -35
  11. data/lib/kamal/cli/healthcheck/poller.rb +1 -1
  12. data/lib/kamal/cli/main.rb +9 -3
  13. data/lib/kamal/cli/proxy.rb +42 -35
  14. data/lib/kamal/cli/registry.rb +37 -7
  15. data/lib/kamal/cli/secrets.rb +2 -1
  16. data/lib/kamal/cli/server.rb +12 -1
  17. data/lib/kamal/cli/templates/deploy.yml +4 -3
  18. data/lib/kamal/cli/templates/secrets +2 -1
  19. data/lib/kamal/commander.rb +21 -19
  20. data/lib/kamal/commands/accessory.rb +5 -0
  21. data/lib/kamal/commands/app/execution.rb +7 -1
  22. data/lib/kamal/commands/app.rb +1 -0
  23. data/lib/kamal/commands/base.rb +15 -2
  24. data/lib/kamal/commands/builder/base.rb +20 -1
  25. data/lib/kamal/commands/builder/hybrid.rb +3 -3
  26. data/lib/kamal/commands/builder/local.rb +8 -2
  27. data/lib/kamal/commands/builder/pack.rb +5 -5
  28. data/lib/kamal/commands/builder/remote.rb +15 -3
  29. data/lib/kamal/commands/builder.rb +8 -2
  30. data/lib/kamal/commands/docker.rb +17 -1
  31. data/lib/kamal/commands/proxy.rb +22 -3
  32. data/lib/kamal/commands/registry.rb +22 -0
  33. data/lib/kamal/configuration/accessory.rb +56 -25
  34. data/lib/kamal/configuration/boot.rb +4 -0
  35. data/lib/kamal/configuration/builder.rb +10 -3
  36. data/lib/kamal/configuration/docs/accessory.yml +37 -5
  37. data/lib/kamal/configuration/docs/alias.yml +3 -0
  38. data/lib/kamal/configuration/docs/boot.yml +12 -10
  39. data/lib/kamal/configuration/docs/configuration.yml +30 -1
  40. data/lib/kamal/configuration/docs/proxy.yml +48 -16
  41. data/lib/kamal/configuration/docs/registry.yml +12 -4
  42. data/lib/kamal/configuration/docs/ssh.yml +7 -4
  43. data/lib/kamal/configuration/docs/sshkit.yml +8 -0
  44. data/lib/kamal/configuration/env.rb +7 -3
  45. data/lib/kamal/configuration/proxy/boot.rb +4 -9
  46. data/lib/kamal/configuration/proxy/run.rb +143 -0
  47. data/lib/kamal/configuration/proxy.rb +7 -3
  48. data/lib/kamal/configuration/registry.rb +8 -0
  49. data/lib/kamal/configuration/role.rb +15 -3
  50. data/lib/kamal/configuration/ssh.rb +18 -3
  51. data/lib/kamal/configuration/sshkit.rb +4 -0
  52. data/lib/kamal/configuration/validator/proxy.rb +20 -0
  53. data/lib/kamal/configuration/validator/registry.rb +5 -3
  54. data/lib/kamal/configuration/validator.rb +52 -4
  55. data/lib/kamal/configuration/volume.rb +11 -4
  56. data/lib/kamal/configuration.rb +89 -5
  57. data/lib/kamal/secrets/adapters/one_password.rb +1 -1
  58. data/lib/kamal/secrets/adapters/passbolt.rb +1 -2
  59. data/lib/kamal/secrets/adapters/test.rb +3 -1
  60. data/lib/kamal/secrets/dotenv/inline_command_substitution.rb +15 -1
  61. data/lib/kamal/secrets.rb +17 -6
  62. data/lib/kamal/sshkit_with_ext.rb +135 -10
  63. data/lib/kamal/utils.rb +3 -3
  64. data/lib/kamal/version.rb +1 -1
  65. data/lib/kamal.rb +1 -0
  66. metadata +18 -2
@@ -24,16 +24,22 @@ class Kamal::Configuration::Validator
24
24
  example_value = example[key]
25
25
 
26
26
  if example_value == "..."
27
- if key.to_s == "ssl"
28
- validate_type! value, TrueClass, FalseClass, Hash
29
- elsif key.to_s != "proxy" || !boolean?(value.class)
27
+ unless key.to_s == "proxy" && boolean?(value.class)
30
28
  validate_type! value, *(Array if key == :servers), Hash
31
29
  end
30
+ elsif key.to_s == "ssl"
31
+ validate_type! value, TrueClass, FalseClass, Hash
32
+ elsif key.to_s == "hooks_output"
33
+ validate_hooks_output!(value)
32
34
  elsif key == "hosts"
33
35
  validate_servers! value
34
36
  elsif example_value.is_a?(Array)
35
37
  if key == "arch"
36
38
  validate_array_of_or_type! value, example_value.first.class
39
+ elsif key.to_s == "config"
40
+ validate_ssh_config!(value)
41
+ elsif key.to_s == "files" || key.to_s == "directories"
42
+ validate_paths!(value)
37
43
  else
38
44
  validate_array_of! value, example_value.first.class
39
45
  end
@@ -129,6 +135,47 @@ class Kamal::Configuration::Validator
129
135
  end
130
136
  end
131
137
 
138
+ def validate_ssh_config!(config)
139
+ if config.is_a?(Array)
140
+ validate_array_of! config, String
141
+ elsif boolean?(config.class) || config.is_a?(String)
142
+ # Booleans and Strings are allowed
143
+ else
144
+ type_error(TrueClass, FalseClass, String, Array)
145
+ end
146
+ end
147
+
148
+ def validate_paths!(paths)
149
+ validate_type! paths, Array
150
+
151
+ paths.each_with_index do |path, index|
152
+ with_context(index) do
153
+ validate_type! path, String, Hash
154
+
155
+ if path.is_a?(Hash)
156
+ %w[local remote mode owner options].each do |key|
157
+ with_context(key) do
158
+ validate_type! path[key], String if path.key?(key)
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
165
+
166
+ def validate_hooks_output!(value)
167
+ # hooks_output can be either a symbol/string (global) or a hash (per-hook)
168
+ if value.is_a?(Hash)
169
+ value.each do |hook, level|
170
+ with_context(hook) do
171
+ validate_type! level, String, Symbol
172
+ end
173
+ end
174
+ else
175
+ validate_type! value, String, Symbol
176
+ end
177
+ end
178
+
132
179
  def validate_type!(value, *types)
133
180
  type_error(*types) unless types.any? { |type| valid_type?(value, type) }
134
181
  end
@@ -138,7 +185,8 @@ class Kamal::Configuration::Validator
138
185
  end
139
186
 
140
187
  def type_error(*expected_types)
141
- error "should be #{expected_types.map { |type| type_description(type) }.join(" or ")}"
188
+ descriptions = expected_types.map { |type| type_description(type) }.uniq
189
+ error "should be #{descriptions.join(" or ")}"
142
190
  end
143
191
 
144
192
  def unknown_keys_error(unknown_keys)
@@ -1,14 +1,21 @@
1
1
  class Kamal::Configuration::Volume
2
- attr_reader :host_path, :container_path
2
+ attr_reader :host_path, :container_path, :options
3
3
  delegate :argumentize, to: Kamal::Utils
4
4
 
5
- def initialize(host_path:, container_path:)
5
+ def initialize(host_path:, container_path:, options: nil)
6
6
  @host_path = host_path
7
7
  @container_path = container_path
8
+ @options = options
8
9
  end
9
10
 
10
11
  def docker_args
11
- argumentize "--volume", "#{host_path_for_docker_volume}:#{container_path}"
12
+ argumentize "--volume", docker_args_string
13
+ end
14
+
15
+ def docker_args_string
16
+ volume_string = "#{host_path_for_docker_volume}:#{container_path}"
17
+ volume_string += ":#{options}" if options.present?
18
+ volume_string
12
19
  end
13
20
 
14
21
  private
@@ -16,7 +23,7 @@ class Kamal::Configuration::Volume
16
23
  if Pathname.new(host_path).absolute?
17
24
  host_path
18
25
  else
19
- File.join "$(pwd)", host_path
26
+ "$PWD/#{host_path}"
20
27
  end
21
28
  end
22
29
  end
@@ -6,7 +6,9 @@ require "erb"
6
6
  require "net/ssh/proxy/jump"
7
7
 
8
8
  class Kamal::Configuration
9
- delegate :service, :image, :labels, :hooks_path, to: :raw_config, allow_nil: true
9
+ HOOKS_OUTPUT_LEVELS = [ :quiet, :verbose ].freeze
10
+
11
+ delegate :service, :labels, :hooks_path, to: :raw_config, allow_nil: true
10
12
  delegate :argumentize, :optionize, to: Kamal::Utils
11
13
 
12
14
  attr_reader :destination, :raw_config, :secrets
@@ -18,11 +20,15 @@ class Kamal::Configuration
18
20
  def create_from(config_file:, destination: nil, version: nil)
19
21
  ENV["KAMAL_DESTINATION"] = destination
20
22
 
21
- raw_config = load_config_files(config_file, *destination_config_file(config_file, destination))
23
+ raw_config = load_raw_config(config_file: config_file, destination: destination)
22
24
 
23
25
  new raw_config, destination: destination, version: version
24
26
  end
25
27
 
28
+ def load_raw_config(config_file:, destination: nil)
29
+ load_config_files(config_file, *destination_config_file(config_file, destination))
30
+ end
31
+
26
32
  private
27
33
  def load_config_files(*files)
28
34
  files.inject({}) { |config, file| config.deep_merge! load_config_file(file) }
@@ -32,7 +38,9 @@ class Kamal::Configuration
32
38
  if file.exist?
33
39
  # Newer Psych doesn't load aliases by default
34
40
  load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load
35
- YAML.send(load_method, ERB.new(File.read(file)).result).symbolize_keys
41
+ template = File.read(file)
42
+ rendered = ERB.new(template, trim_mode: "-").result
43
+ YAML.send(load_method, rendered).symbolize_keys
36
44
  else
37
45
  raise "Configuration file not found in #{file}"
38
46
  end
@@ -50,7 +58,7 @@ class Kamal::Configuration
50
58
 
51
59
  validate! raw_config, example: validation_yml.symbolize_keys, context: "", with: Kamal::Configuration::Validator::Configuration
52
60
 
53
- @secrets = Kamal::Secrets.new(destination: destination)
61
+ @secrets = Kamal::Secrets.new(destination: destination, secrets_path: secrets_path)
54
62
 
55
63
  # Eager load config to validate it, these are first as they have dependencies later on
56
64
  @servers = Servers.new(config: self)
@@ -76,6 +84,9 @@ class Kamal::Configuration
76
84
  ensure_no_traefik_reboot_hooks
77
85
  ensure_one_host_for_ssl_roles
78
86
  ensure_unique_hosts_for_ssl_roles
87
+ ensure_local_registry_remote_builder_has_ssh_url
88
+ ensure_no_conflicting_proxy_runs
89
+ ensure_valid_hooks_output!
79
90
  end
80
91
 
81
92
  def version=(version)
@@ -121,6 +132,14 @@ class Kamal::Configuration
121
132
  (roles + accessories).flat_map(&:hosts).uniq
122
133
  end
123
134
 
135
+ def host_roles(host)
136
+ roles.select { |role| role.hosts.include?(host) }
137
+ end
138
+
139
+ def host_accessories(host)
140
+ accessories.select { |accessory| accessory.hosts.include?(host) }
141
+ end
142
+
124
143
  def app_hosts
125
144
  roles.flat_map(&:hosts).uniq
126
145
  end
@@ -157,6 +176,18 @@ class Kamal::Configuration
157
176
  (proxy_roles.flat_map(&:hosts) + proxy_accessories.flat_map(&:hosts)).uniq
158
177
  end
159
178
 
179
+ def image
180
+ name = raw_config&.image.presence
181
+ name ||= raw_config&.service if registry.local?
182
+
183
+ name
184
+ end
185
+
186
+ def proxy_run(host)
187
+ # We validate that all the config are identical for a host
188
+ proxy_runs(host.to_s).first
189
+ end
190
+
160
191
  def repository
161
192
  [ registry.server, image ].compact.join("/")
162
193
  end
@@ -233,6 +264,10 @@ class Kamal::Configuration
233
264
  raw_config.hooks_path || ".kamal/hooks"
234
265
  end
235
266
 
267
+ def secrets_path
268
+ raw_config.secrets_path || ".kamal/secrets"
269
+ end
270
+
236
271
  def asset_path
237
272
  raw_config.asset_path
238
273
  end
@@ -253,6 +288,15 @@ class Kamal::Configuration
253
288
  env_tags.detect { |t| t.name == name.to_s }
254
289
  end
255
290
 
291
+ def hooks_output_for(hook)
292
+ case raw_config.hooks_output
293
+ when Symbol, String
294
+ raw_config.hooks_output.to_sym
295
+ when Hash
296
+ raw_config.hooks_output[hook]&.to_sym
297
+ end
298
+ end
299
+
256
300
  def to_h
257
301
  {
258
302
  roles: role_names,
@@ -282,10 +326,12 @@ class Kamal::Configuration
282
326
  end
283
327
 
284
328
  def ensure_required_keys_present
285
- %i[ service image registry ].each do |key|
329
+ %i[ service registry ].each do |key|
286
330
  raise Kamal::ConfigurationError, "Missing required configuration for #{key}" unless raw_config[key].present?
287
331
  end
288
332
 
333
+ raise Kamal::ConfigurationError, "Missing required configuration for image" if image.blank?
334
+
289
335
  if raw_config.servers.nil?
290
336
  raise Kamal::ConfigurationError, "No servers or accessories specified" unless raw_config.accessories.present?
291
337
  else
@@ -354,10 +400,48 @@ class Kamal::Configuration
354
400
  true
355
401
  end
356
402
 
403
+ def ensure_local_registry_remote_builder_has_ssh_url
404
+ if registry.local? && builder.remote?
405
+ unless URI(builder.remote).scheme == "ssh"
406
+ raise Kamal::ConfigurationError, "Local registry with remote builder requires an SSH URL (e.g., ssh://user@host)"
407
+ end
408
+ end
409
+
410
+ true
411
+ end
412
+
413
+ def ensure_no_conflicting_proxy_runs
414
+ all_hosts.each do |host|
415
+ run_configs = proxy_runs(host)
416
+ if run_configs.uniq.size > 1
417
+ raise Kamal::ConfigurationError, "Conflicting proxy run configurations for host #{host}"
418
+ end
419
+ end
420
+ end
421
+
422
+ def proxy_runs(host)
423
+ (host_roles(host) + host_accessories(host)).map(&:proxy).compact.map(&:run).compact
424
+ end
425
+
357
426
  def role_names
358
427
  raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort
359
428
  end
360
429
 
430
+ def ensure_valid_hooks_output!
431
+ case raw_config.hooks_output
432
+ when Symbol, String
433
+ validate_hooks_output_level!(raw_config.hooks_output.to_sym)
434
+ when Hash
435
+ raw_config.hooks_output.each { |hook, level| validate_hooks_output_level!(level.to_sym, hook) }
436
+ end
437
+ end
438
+
439
+ def validate_hooks_output_level!(level, hook = nil)
440
+ return if HOOKS_OUTPUT_LEVELS.include?(level)
441
+ context = hook ? " for hook '#{hook}'" : ""
442
+ raise Kamal::ConfigurationError, "Invalid hooks_output '#{level}'#{context}, must be one of: #{HOOKS_OUTPUT_LEVELS.join(', ')}"
443
+ end
444
+
361
445
  def git_version
362
446
  @git_version ||=
363
447
  if Kamal::Git.used?
@@ -17,7 +17,7 @@ class Kamal::Secrets::Adapters::OnePassword < Kamal::Secrets::Adapters::Base
17
17
 
18
18
  def fetch_secrets(secrets, from:, account:, session:)
19
19
  if secrets.blank?
20
- fetch_all_secrets(from: from, account: account, session: session) if secrets.blank?
20
+ fetch_all_secrets(from: from, account: account, session: session)
21
21
  else
22
22
  fetch_specified_secrets(secrets, from: from, account: account, session: session)
23
23
  end
@@ -47,9 +47,8 @@ class Kamal::Secrets::Adapters::Passbolt < Kamal::Secrets::Adapters::Base
47
47
  end
48
48
 
49
49
  filter_condition = filter_conditions.any? ? "--filter '#{filter_conditions.join(" || ")}'" : ""
50
- items = `passbolt list resources #{filter_condition} #{folders.map { |item| "--folder #{item["id"]}" }.join(" ")} --json`
50
+ items = `passbolt list resources #{filter_condition} #{folders.map { |item| "--folder #{item["id"].to_s.shellescape}" }.join(" ")} --json`
51
51
  raise RuntimeError, "Could not read #{secrets} from Passbolt" unless $?.success?
52
-
53
52
  items = JSON.parse(items)
54
53
  found_names = items.map { |item| item["name"] }
55
54
  missing_secrets = secret_names - found_names
@@ -5,7 +5,9 @@ class Kamal::Secrets::Adapters::Test < Kamal::Secrets::Adapters::Base
5
5
  end
6
6
 
7
7
  def fetch_secrets(secrets, from:, account:, session:)
8
- prefixed_secrets(secrets, from: from).to_h { |secret| [ secret, secret.reverse ] }
8
+ prefixed_secrets(secrets, from: from).to_h do |secret|
9
+ [ secret, secret.gsub("LPAREN", "(").gsub("RPAREN", ")").reverse ]
10
+ end
9
11
  end
10
12
 
11
13
  def check_dependencies!
@@ -1,4 +1,18 @@
1
1
  class Kamal::Secrets::Dotenv::InlineCommandSubstitution
2
+ # Unlike dotenv, this regex does not match escaped
3
+ # parentheses when looking for command substitutions.
4
+ INTERPOLATED_SHELL_COMMAND = /
5
+ (?<backslash>\\)? # is it escaped with a backslash?
6
+ \$ # literal $
7
+ (?<cmd> # collect command content for eval
8
+ \( # require opening paren
9
+ (?:\\.|[^()\\]|\g<cmd>)+ # allow any number of non-parens or escaped
10
+ # parens (by nesting the <cmd> expression
11
+ # recursively)
12
+ \) # require closing paren
13
+ )
14
+ /x
15
+
2
16
  class << self
3
17
  def install!
4
18
  ::Dotenv::Parser.substitutions.map! { |sub| sub == ::Dotenv::Substitutions::Command ? self : sub }
@@ -6,7 +20,7 @@ class Kamal::Secrets::Dotenv::InlineCommandSubstitution
6
20
 
7
21
  def call(value, env, overwrite: false)
8
22
  # Process interpolated shell commands
9
- value.gsub(Dotenv::Substitutions::Command.singleton_class::INTERPOLATED_SHELL_COMMAND) do |*|
23
+ value.gsub(INTERPOLATED_SHELL_COMMAND) do |*|
10
24
  # Eliminate opening and closing parentheses
11
25
  command = $LAST_MATCH_INFO[:cmd][1..-2]
12
26
 
data/lib/kamal/secrets.rb CHANGED
@@ -3,16 +3,14 @@ require "dotenv"
3
3
  class Kamal::Secrets
4
4
  Kamal::Secrets::Dotenv::InlineCommandSubstitution.install!
5
5
 
6
- def initialize(destination: nil)
6
+ def initialize(destination: nil, secrets_path:)
7
7
  @destination = destination
8
+ @secrets_path = secrets_path
8
9
  @mutex = Mutex.new
9
10
  end
10
11
 
11
12
  def [](key)
12
- # Fetching secrets may ask the user for input, so ensure only one thread does that
13
- @mutex.synchronize do
14
- secrets.fetch(key)
15
- end
13
+ synchronized_fetch(key)
16
14
  rescue KeyError
17
15
  if secrets_files.present?
18
16
  raise Kamal::ConfigurationError, "Secret '#{key}' not found in #{secrets_files.join(", ")}"
@@ -29,6 +27,12 @@ class Kamal::Secrets
29
27
  @secrets_files ||= secrets_filenames.select { |f| File.exist?(f) }
30
28
  end
31
29
 
30
+ def key?(key)
31
+ synchronized_fetch(key).present?
32
+ rescue KeyError
33
+ false
34
+ end
35
+
32
36
  private
33
37
  def secrets
34
38
  @secrets ||= secrets_files.inject({}) do |secrets, secrets_file|
@@ -37,6 +41,13 @@ class Kamal::Secrets
37
41
  end
38
42
 
39
43
  def secrets_filenames
40
- [ ".kamal/secrets-common", ".kamal/secrets#{(".#{@destination}" if @destination)}" ]
44
+ [ "#{@secrets_path}-common", "#{@secrets_path}#{(".#{@destination}" if @destination)}" ]
45
+ end
46
+
47
+ def synchronized_fetch(key)
48
+ # Fetching secrets may ask the user for input, so ensure only one thread does that
49
+ @mutex.synchronize do
50
+ secrets.fetch(key)
51
+ end
41
52
  end
42
53
  end
@@ -3,6 +3,7 @@ require "sshkit/dsl"
3
3
  require "net/scp"
4
4
  require "active_support/core_ext/hash/deep_merge"
5
5
  require "json"
6
+ require "resolv"
6
7
  require "concurrent/atomic/semaphore"
7
8
 
8
9
  class SSHKit::Backend::Abstract
@@ -18,8 +19,11 @@ class SSHKit::Backend::Abstract
18
19
  JSON.pretty_generate(JSON.parse(capture(*args, **kwargs)))
19
20
  end
20
21
 
21
- def puts_by_host(host, output, type: "App")
22
- puts "#{type} Host: #{host}\n#{output}\n\n"
22
+ def puts_by_host(host, output, type: "App", quiet: false)
23
+ unless quiet
24
+ puts "#{type} Host: #{host}"
25
+ end
26
+ puts "#{output}\n\n"
23
27
  end
24
28
 
25
29
  # Our execution pattern is for the CLI execute args lists returned
@@ -58,10 +62,50 @@ class SSHKit::Backend::Abstract
58
62
  end
59
63
 
60
64
  class SSHKit::Backend::Netssh::Configuration
61
- attr_accessor :max_concurrent_starts
65
+ attr_accessor :max_concurrent_starts, :dns_retries
62
66
  end
63
67
 
64
68
  class SSHKit::Backend::Netssh
69
+ module DnsRetriable
70
+ DNS_RETRY_BASE = 0.1
71
+ DNS_RETRY_MAX = 2.0
72
+ DNS_RETRY_JITTER = 0.1
73
+ DNS_ERROR_MESSAGE = /getaddrinfo|Temporary failure in name resolution|Name or service not known|nodename nor servname provided|No address associated|failed to look up|resolve/i
74
+
75
+ def with_dns_retry(hostname, retries: config.dns_retries, base: DNS_RETRY_BASE, max_sleep: DNS_RETRY_MAX, jitter: DNS_RETRY_JITTER)
76
+ attempts = 0
77
+ begin
78
+ attempts += 1
79
+ yield
80
+ rescue => error
81
+ raise unless retryable_dns_error?(error) && attempts <= retries
82
+
83
+ delay = dns_retry_sleep(attempts, base: base, jitter: jitter, max_sleep: max_sleep)
84
+ SSHKit.config.output.warn("Retrying DNS for #{hostname} (attempt #{attempts}/#{retries}) in #{format("%0.2f", delay)}s: #{error.message}")
85
+ sleep delay
86
+ retry
87
+ end
88
+ end
89
+
90
+ private
91
+ def retryable_dns_error?(error)
92
+ case error
93
+ when Resolv::ResolvError, Resolv::ResolvTimeout
94
+ true
95
+ when SocketError
96
+ error.message =~ DNS_ERROR_MESSAGE
97
+ else
98
+ error.cause && retryable_dns_error?(error.cause)
99
+ end
100
+ end
101
+
102
+ def dns_retry_sleep(attempt, base:, jitter:, max_sleep:)
103
+ sleep_for = [ base * (2 ** (attempt - 1)), max_sleep ].min
104
+ sleep_for += Kernel.rand * jitter
105
+ sleep_for
106
+ end
107
+ end
108
+
65
109
  module LimitConcurrentStartsClass
66
110
  attr_reader :start_semaphore
67
111
 
@@ -76,14 +120,31 @@ class SSHKit::Backend::Netssh
76
120
 
77
121
  class << self
78
122
  prepend LimitConcurrentStartsClass
123
+ prepend DnsRetriable
79
124
  end
80
125
 
126
+ module ConnectSsh
127
+ private
128
+ def connect_ssh(...)
129
+ Net::SSH.start(...)
130
+ end
131
+ end
132
+ include ConnectSsh
133
+
134
+ module DnsRetriableConnection
135
+ private
136
+ def connect_ssh(...)
137
+ self.class.with_dns_retry(host.hostname) { super }
138
+ end
139
+ end
140
+ prepend DnsRetriableConnection
141
+
81
142
  module LimitConcurrentStartsInstance
82
143
  private
83
144
  def with_ssh(&block)
84
145
  host.ssh_options = self.class.config.ssh_options.merge(host.ssh_options || {})
85
146
  self.class.pool.with(
86
- method(:start_with_concurrency_limit),
147
+ method(:connect_ssh),
87
148
  String(host.hostname),
88
149
  host.username,
89
150
  host.netssh_options,
@@ -91,17 +152,18 @@ class SSHKit::Backend::Netssh
91
152
  )
92
153
  end
93
154
 
94
- def start_with_concurrency_limit(*args)
155
+ def connect_ssh(...)
156
+ with_concurrency_limit { super }
157
+ end
158
+
159
+ def with_concurrency_limit(&block)
95
160
  if self.class.start_semaphore
96
- self.class.start_semaphore.acquire do
97
- Net::SSH.start(*args)
98
- end
161
+ self.class.start_semaphore.acquire(&block)
99
162
  else
100
- Net::SSH.start(*args)
163
+ yield
101
164
  end
102
165
  end
103
166
  end
104
-
105
167
  prepend LimitConcurrentStartsInstance
106
168
  end
107
169
 
@@ -140,3 +202,66 @@ class SSHKit::Runner::Parallel
140
202
 
141
203
  prepend CompleteAll
142
204
  end
205
+
206
+ # Avoid net-ssh debug, until https://github.com/net-ssh/net-ssh/pull/953 is merged
207
+ module NetSshForwardingNoPuts
208
+ def puts(*)
209
+ end
210
+ end
211
+
212
+ Net::SSH::Service::Forward.prepend NetSshForwardingNoPuts
213
+
214
+ module SSHKitDslRoles
215
+ # Execute on hosts grouped by role.
216
+ #
217
+ # Unlike `on()` which deduplicates hosts, this allows the same host to have
218
+ # multiple concurrent connections when it appears in multiple roles.
219
+ #
220
+ # Options:
221
+ # hosts: The hosts to run on (required)
222
+ # parallel: When true, each role runs in its own thread with separate
223
+ # connections. When false, hosts run in parallel but roles on each
224
+ # host run sequentially (default: true)
225
+ #
226
+ # Example:
227
+ # on_roles(roles) do |host, role|
228
+ # # deploy role to host
229
+ # end
230
+ def on_roles(roles, hosts:, parallel: true, &block)
231
+ if parallel
232
+ threads = roles.filter_map do |role|
233
+ if (role_hosts = role.hosts & hosts).any?
234
+ Thread.new do
235
+ on(role_hosts) { |host| instance_exec(host, role, &block) }
236
+ rescue StandardError => e
237
+ raise SSHKit::Runner::ExecuteError.new(e), "Exception while executing on #{role}: #{e.message}"
238
+ end
239
+ end
240
+ end
241
+
242
+ exceptions = []
243
+ threads.each do |t|
244
+ begin
245
+ t.join
246
+ rescue SSHKit::Runner::ExecuteError => e
247
+ exceptions << e
248
+ end
249
+ end
250
+
251
+ if exceptions.one?
252
+ raise exceptions.first
253
+ elsif exceptions.many?
254
+ raise exceptions.first, [ "Exceptions on #{exceptions.count} roles:", exceptions.map(&:message) ].join("\n")
255
+ end
256
+ else
257
+ # Host-first iteration: hosts run in parallel, roles on each host run sequentially
258
+ on(hosts) do |host|
259
+ roles.each do |role|
260
+ instance_exec(host, role, &block) if role.hosts.include?(host.to_s)
261
+ end
262
+ end
263
+ end
264
+ end
265
+ end
266
+
267
+ SSHKit::DSL.prepend SSHKitDslRoles
data/lib/kamal/utils.rb CHANGED
@@ -21,11 +21,11 @@ module Kamal::Utils
21
21
  end
22
22
 
23
23
  # Returns a list of shell-dashed option arguments. If the value is true, it's treated like a value-less option.
24
- def optionize(args, with: nil)
24
+ def optionize(args, with: nil, escape: true)
25
25
  options = if with
26
- flatten_args(args).collect { |(key, value)| value == true ? "--#{key}" : "--#{key}#{with}#{escape_shell_value(value)}" }
26
+ flatten_args(args).collect { |(key, value)| value == true ? "--#{key}" : "--#{key}#{with}#{escape ? escape_shell_value(value) : value}" }
27
27
  else
28
- flatten_args(args).collect { |(key, value)| [ "--#{key}", value == true ? nil : escape_shell_value(value) ] }
28
+ flatten_args(args).collect { |(key, value)| [ "--#{key}", value == true ? nil : escape ? escape_shell_value(value) : value ] }
29
29
  end
30
30
 
31
31
  options.flatten.compact
data/lib/kamal/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Kamal
2
- VERSION = "2.7.0"
2
+ VERSION = "2.11.0"
3
3
  end
data/lib/kamal.rb CHANGED
@@ -7,6 +7,7 @@ require "zeitwerk"
7
7
  require "yaml"
8
8
  require "tmpdir"
9
9
  require "pathname"
10
+ require "uri"
10
11
 
11
12
  loader = Zeitwerk::Loader.for_gem
12
13
  loader.ignore(File.join(__dir__, "kamal", "sshkit_with_ext.rb"))
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kamal
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.7.0
4
+ version: 2.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Heinemeier Hansson
@@ -175,6 +175,20 @@ dependencies:
175
175
  - - ">="
176
176
  - !ruby/object:Gem::Version
177
177
  version: '0'
178
+ - !ruby/object:Gem::Dependency
179
+ name: minitest
180
+ requirement: !ruby/object:Gem::Requirement
181
+ requirements:
182
+ - - "<"
183
+ - !ruby/object:Gem::Version
184
+ version: '6'
185
+ type: :development
186
+ prerelease: false
187
+ version_requirements: !ruby/object:Gem::Requirement
188
+ requirements:
189
+ - - "<"
190
+ - !ruby/object:Gem::Version
191
+ version: '6'
178
192
  - !ruby/object:Gem::Dependency
179
193
  name: mocha
180
194
  requirement: !ruby/object:Gem::Requirement
@@ -224,6 +238,7 @@ files:
224
238
  - lib/kamal/cli/base.rb
225
239
  - lib/kamal/cli/build.rb
226
240
  - lib/kamal/cli/build/clone.rb
241
+ - lib/kamal/cli/build/port_forwarding.rb
227
242
  - lib/kamal/cli/healthcheck/barrier.rb
228
243
  - lib/kamal/cli/healthcheck/error.rb
229
244
  - lib/kamal/cli/healthcheck/poller.rb
@@ -298,6 +313,7 @@ files:
298
313
  - lib/kamal/configuration/logging.rb
299
314
  - lib/kamal/configuration/proxy.rb
300
315
  - lib/kamal/configuration/proxy/boot.rb
316
+ - lib/kamal/configuration/proxy/run.rb
301
317
  - lib/kamal/configuration/registry.rb
302
318
  - lib/kamal/configuration/role.rb
303
319
  - lib/kamal/configuration/servers.rb
@@ -355,7 +371,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
355
371
  - !ruby/object:Gem::Version
356
372
  version: '0'
357
373
  requirements: []
358
- rubygems_version: 3.6.7
374
+ rubygems_version: 3.6.9
359
375
  specification_version: 4
360
376
  summary: Deploy web apps in containers to servers running Docker with zero downtime.
361
377
  test_files: []