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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/kamal/cli/accessory.rb +27 -7
- data/lib/kamal/cli/alias/command.rb +2 -2
- data/lib/kamal/cli/app/boot.rb +1 -1
- data/lib/kamal/cli/app.rb +74 -115
- data/lib/kamal/cli/base.rb +19 -6
- data/lib/kamal/cli/build/clone.rb +0 -2
- data/lib/kamal/cli/build/port_forwarding.rb +66 -0
- data/lib/kamal/cli/build.rb +70 -35
- data/lib/kamal/cli/healthcheck/poller.rb +1 -1
- data/lib/kamal/cli/main.rb +9 -3
- data/lib/kamal/cli/proxy.rb +42 -35
- data/lib/kamal/cli/registry.rb +37 -7
- data/lib/kamal/cli/secrets.rb +2 -1
- data/lib/kamal/cli/server.rb +12 -1
- data/lib/kamal/cli/templates/deploy.yml +4 -3
- data/lib/kamal/cli/templates/secrets +2 -1
- data/lib/kamal/commander.rb +21 -19
- data/lib/kamal/commands/accessory.rb +5 -0
- data/lib/kamal/commands/app/execution.rb +7 -1
- data/lib/kamal/commands/app.rb +1 -0
- data/lib/kamal/commands/base.rb +15 -2
- data/lib/kamal/commands/builder/base.rb +20 -1
- data/lib/kamal/commands/builder/hybrid.rb +3 -3
- data/lib/kamal/commands/builder/local.rb +8 -2
- data/lib/kamal/commands/builder/pack.rb +5 -5
- data/lib/kamal/commands/builder/remote.rb +15 -3
- data/lib/kamal/commands/builder.rb +8 -2
- data/lib/kamal/commands/docker.rb +17 -1
- data/lib/kamal/commands/proxy.rb +22 -3
- data/lib/kamal/commands/registry.rb +22 -0
- data/lib/kamal/configuration/accessory.rb +56 -25
- data/lib/kamal/configuration/boot.rb +4 -0
- data/lib/kamal/configuration/builder.rb +10 -3
- data/lib/kamal/configuration/docs/accessory.yml +37 -5
- data/lib/kamal/configuration/docs/alias.yml +3 -0
- data/lib/kamal/configuration/docs/boot.yml +12 -10
- data/lib/kamal/configuration/docs/configuration.yml +30 -1
- data/lib/kamal/configuration/docs/proxy.yml +48 -16
- data/lib/kamal/configuration/docs/registry.yml +12 -4
- data/lib/kamal/configuration/docs/ssh.yml +7 -4
- data/lib/kamal/configuration/docs/sshkit.yml +8 -0
- data/lib/kamal/configuration/env.rb +7 -3
- data/lib/kamal/configuration/proxy/boot.rb +4 -9
- data/lib/kamal/configuration/proxy/run.rb +143 -0
- data/lib/kamal/configuration/proxy.rb +7 -3
- data/lib/kamal/configuration/registry.rb +8 -0
- data/lib/kamal/configuration/role.rb +15 -3
- data/lib/kamal/configuration/ssh.rb +18 -3
- data/lib/kamal/configuration/sshkit.rb +4 -0
- data/lib/kamal/configuration/validator/proxy.rb +20 -0
- data/lib/kamal/configuration/validator/registry.rb +5 -3
- data/lib/kamal/configuration/validator.rb +52 -4
- data/lib/kamal/configuration/volume.rb +11 -4
- data/lib/kamal/configuration.rb +89 -5
- data/lib/kamal/secrets/adapters/one_password.rb +1 -1
- data/lib/kamal/secrets/adapters/passbolt.rb +1 -2
- data/lib/kamal/secrets/adapters/test.rb +3 -1
- data/lib/kamal/secrets/dotenv/inline_command_substitution.rb +15 -1
- data/lib/kamal/secrets.rb +17 -6
- data/lib/kamal/sshkit_with_ext.rb +135 -10
- data/lib/kamal/utils.rb +3 -3
- data/lib/kamal/version.rb +1 -1
- data/lib/kamal.rb +1 -0
- 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
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
26
|
+
"$PWD/#{host_path}"
|
|
20
27
|
end
|
|
21
28
|
end
|
|
22
29
|
end
|
data/lib/kamal/configuration.rb
CHANGED
|
@@ -6,7 +6,9 @@ require "erb"
|
|
|
6
6
|
require "net/ssh/proxy/jump"
|
|
7
7
|
|
|
8
8
|
class Kamal::Configuration
|
|
9
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
[ "
|
|
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
|
-
|
|
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(:
|
|
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
|
|
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
|
|
97
|
-
Net::SSH.start(*args)
|
|
98
|
-
end
|
|
161
|
+
self.class.start_semaphore.acquire(&block)
|
|
99
162
|
else
|
|
100
|
-
|
|
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
data/lib/kamal.rb
CHANGED
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.
|
|
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.
|
|
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: []
|