kamal 2.8.2 → 2.10.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/lib/kamal/cli/accessory.rb +14 -7
  3. data/lib/kamal/cli/app/boot.rb +1 -1
  4. data/lib/kamal/cli/app.rb +74 -115
  5. data/lib/kamal/cli/healthcheck/poller.rb +1 -1
  6. data/lib/kamal/cli/main.rb +2 -1
  7. data/lib/kamal/cli/proxy.rb +42 -35
  8. data/lib/kamal/cli/secrets.rb +2 -1
  9. data/lib/kamal/cli/server.rb +2 -1
  10. data/lib/kamal/cli/templates/secrets +1 -0
  11. data/lib/kamal/commander.rb +3 -2
  12. data/lib/kamal/commands/app/execution.rb +7 -1
  13. data/lib/kamal/commands/app.rb +1 -1
  14. data/lib/kamal/commands/proxy.rb +21 -2
  15. data/lib/kamal/configuration/accessory.rb +63 -26
  16. data/lib/kamal/configuration/boot.rb +4 -0
  17. data/lib/kamal/configuration/builder.rb +10 -3
  18. data/lib/kamal/configuration/docs/accessory.yml +37 -5
  19. data/lib/kamal/configuration/docs/boot.yml +12 -10
  20. data/lib/kamal/configuration/docs/configuration.yml +10 -1
  21. data/lib/kamal/configuration/docs/proxy.yml +24 -0
  22. data/lib/kamal/configuration/docs/ssh.yml +7 -4
  23. data/lib/kamal/configuration/docs/sshkit.yml +8 -0
  24. data/lib/kamal/configuration/env.rb +7 -3
  25. data/lib/kamal/configuration/proxy/boot.rb +4 -9
  26. data/lib/kamal/configuration/proxy/run.rb +143 -0
  27. data/lib/kamal/configuration/proxy.rb +2 -2
  28. data/lib/kamal/configuration/role.rb +15 -3
  29. data/lib/kamal/configuration/ssh.rb +18 -3
  30. data/lib/kamal/configuration/sshkit.rb +4 -0
  31. data/lib/kamal/configuration/validator/proxy.rb +20 -0
  32. data/lib/kamal/configuration/validator.rb +34 -1
  33. data/lib/kamal/configuration/volume.rb +11 -4
  34. data/lib/kamal/configuration.rb +32 -1
  35. data/lib/kamal/secrets/adapters/passbolt.rb +1 -2
  36. data/lib/kamal/secrets/adapters/test.rb +3 -1
  37. data/lib/kamal/secrets/dotenv/inline_command_substitution.rb +15 -1
  38. data/lib/kamal/secrets.rb +17 -6
  39. data/lib/kamal/sshkit_with_ext.rb +127 -10
  40. data/lib/kamal/utils.rb +3 -3
  41. data/lib/kamal/version.rb +1 -1
  42. metadata +2 -1
@@ -0,0 +1,143 @@
1
+ class Kamal::Configuration::Proxy::Run
2
+ MINIMUM_VERSION = "v0.9.0"
3
+ DEFAULT_HTTP_PORT = 80
4
+ DEFAULT_HTTPS_PORT = 443
5
+ DEFAULT_LOG_MAX_SIZE = "10m"
6
+
7
+ attr_reader :config, :run_config
8
+ delegate :argumentize, :optionize, to: Kamal::Utils
9
+
10
+ def initialize(config, run_config:, context: "proxy/run")
11
+ @config = config
12
+ @run_config = run_config
13
+ @context = context
14
+ end
15
+
16
+ def debug?
17
+ run_config.fetch("debug", nil)
18
+ end
19
+
20
+ def publish?
21
+ run_config.fetch("publish", true)
22
+ end
23
+
24
+ def http_port
25
+ run_config.fetch("http_port", DEFAULT_HTTP_PORT)
26
+ end
27
+
28
+ def https_port
29
+ run_config.fetch("https_port", DEFAULT_HTTPS_PORT)
30
+ end
31
+
32
+ def bind_ips
33
+ run_config.fetch("bind_ips", nil)
34
+ end
35
+
36
+ def publish_args
37
+ if publish?
38
+ (bind_ips || [ nil ]).map do |bind_ip|
39
+ bind_ip = format_bind_ip(bind_ip)
40
+ publish_http = [ bind_ip, http_port, DEFAULT_HTTP_PORT ].compact.join(":")
41
+ publish_https = [ bind_ip, https_port, DEFAULT_HTTPS_PORT ].compact.join(":")
42
+
43
+ argumentize "--publish", [ publish_http, publish_https ]
44
+ end.join(" ")
45
+ end
46
+ end
47
+
48
+ def log_max_size
49
+ run_config.fetch("log_max_size", DEFAULT_LOG_MAX_SIZE)
50
+ end
51
+
52
+ def logging_args
53
+ argumentize "--log-opt", "max-size=#{log_max_size}" if log_max_size.present?
54
+ end
55
+
56
+ def version
57
+ run_config.fetch("version", MINIMUM_VERSION)
58
+ end
59
+
60
+ def registry
61
+ run_config.fetch("registry", nil)
62
+ end
63
+
64
+ def repository
65
+ run_config.fetch("repository", "basecamp/kamal-proxy")
66
+ end
67
+
68
+ def image
69
+ "#{[ registry, repository ].compact.join("/")}:#{version}"
70
+ end
71
+
72
+ def container_name
73
+ "kamal-proxy"
74
+ end
75
+
76
+ def options_args
77
+ if args = run_config["options"]
78
+ optionize args
79
+ end
80
+ end
81
+
82
+ def run_command
83
+ [ "kamal-proxy", "run", *optionize(run_command_options) ].join(" ")
84
+ end
85
+
86
+ def metrics_port
87
+ run_config["metrics_port"]
88
+ end
89
+
90
+ def run_command_options
91
+ { debug: debug? || nil, "metrics-port": metrics_port }.compact
92
+ end
93
+
94
+ def docker_options_args
95
+ [
96
+ *apps_volume_args,
97
+ *publish_args,
98
+ *logging_args,
99
+ *("--expose=#{metrics_port}" if metrics_port.present?),
100
+ *options_args
101
+ ].compact
102
+ end
103
+
104
+ def host_directory
105
+ File.join config.run_directory, "proxy"
106
+ end
107
+
108
+ def apps_directory
109
+ File.join host_directory, "apps-config"
110
+ end
111
+
112
+ def apps_container_directory
113
+ "/home/kamal-proxy/.apps-config"
114
+ end
115
+
116
+ def apps_volume
117
+ Kamal::Configuration::Volume.new \
118
+ host_path: apps_directory,
119
+ container_path: apps_container_directory
120
+ end
121
+
122
+ def apps_volume_args
123
+ [ apps_volume.docker_args ]
124
+ end
125
+
126
+ def app_directory
127
+ File.join apps_directory, config.service_and_destination
128
+ end
129
+
130
+ def app_container_directory
131
+ File.join apps_container_directory, config.service_and_destination
132
+ end
133
+
134
+ private
135
+ def format_bind_ip(ip)
136
+ # Ensure IPv6 address inside square brackets - e.g. [::1]
137
+ if ip =~ Resolv::IPv6::Regex && ip !~ /\A\[.*\]\z/
138
+ "[#{ip}]"
139
+ else
140
+ ip
141
+ end
142
+ end
143
+ end
@@ -6,8 +6,7 @@ class Kamal::Configuration::Proxy
6
6
 
7
7
  delegate :argumentize, :optionize, to: Kamal::Utils
8
8
 
9
- attr_reader :config, :proxy_config, :role_name, :secrets
10
-
9
+ attr_reader :config, :proxy_config, :role_name, :run, :secrets
11
10
  def initialize(config:, proxy_config:, role_name: nil, secrets:, context: "proxy")
12
11
  @config = config
13
12
  @proxy_config = proxy_config
@@ -15,6 +14,7 @@ class Kamal::Configuration::Proxy
15
14
  @role_name = role_name
16
15
  @secrets = secrets
17
16
  validate! @proxy_config, with: Kamal::Configuration::Validator::Proxy, context: context
17
+ @run = Kamal::Configuration::Proxy::Run.new(config, run_config: @proxy_config["run"], context: "#{context}/run") if @proxy_config && @proxy_config["run"].present?
18
18
  end
19
19
 
20
20
  def app_port
@@ -36,7 +36,7 @@ class Kamal::Configuration::Role
36
36
  end
37
37
 
38
38
  def env_tags(host)
39
- tagged_hosts.fetch(host).collect { |tag| config.env_tag(tag) }
39
+ tagged_hosts.fetch(host).collect { |tag| config.env_tag(tag) }.compact
40
40
  end
41
41
 
42
42
  def cmd
@@ -127,7 +127,7 @@ class Kamal::Configuration::Role
127
127
 
128
128
 
129
129
  def asset_path
130
- specializations["asset_path"] || config.asset_path
130
+ asset_path_config&.dig(0)
131
131
  end
132
132
 
133
133
  def assets?
@@ -137,10 +137,14 @@ class Kamal::Configuration::Role
137
137
  def asset_volume(version = config.version)
138
138
  if assets?
139
139
  Kamal::Configuration::Volume.new \
140
- host_path: asset_volume_directory(version), container_path: asset_path
140
+ host_path: asset_volume_directory(version), container_path: asset_path, options: asset_path_options
141
141
  end
142
142
  end
143
143
 
144
+ def asset_path_options
145
+ asset_path_config&.dig(1)
146
+ end
147
+
144
148
  def asset_extracted_directory(version = config.version)
145
149
  File.join config.assets_directory, "extracted", [ name, version ].join("-")
146
150
  end
@@ -219,4 +223,12 @@ class Kamal::Configuration::Role
219
223
  labels.merge!(specializations["labels"]) if specializations["labels"].present?
220
224
  end
221
225
  end
226
+
227
+ def asset_path_config
228
+ raw_path = specializations["asset_path"] || config.asset_path
229
+ return nil unless raw_path.present?
230
+
231
+ parts = raw_path.split(":", 2)
232
+ [ parts[0], parts[1] ]
233
+ end
222
234
  end
@@ -3,10 +3,11 @@ class Kamal::Configuration::Ssh
3
3
 
4
4
  include Kamal::Configuration::Validation
5
5
 
6
- attr_reader :ssh_config
6
+ attr_reader :ssh_config, :secrets
7
7
 
8
8
  def initialize(config:)
9
9
  @ssh_config = config.raw_config.ssh || {}
10
+ @secrets = config.secrets
10
11
  validate! ssh_config
11
12
  end
12
13
 
@@ -35,11 +36,25 @@ class Kamal::Configuration::Ssh
35
36
  end
36
37
 
37
38
  def key_data
38
- ssh_config["key_data"]
39
+ key_data = ssh_config["key_data"]
40
+ return unless key_data
41
+
42
+ key_data.map do |k|
43
+ if secrets.key?(k)
44
+ secrets[k]
45
+ else
46
+ warn "Inline key_data usage is deprecated and will be removed in Kamal 3. Please store your key_data in a secret."
47
+ k
48
+ end
49
+ end
50
+ end
51
+
52
+ def config
53
+ ssh_config["config"]
39
54
  end
40
55
 
41
56
  def options
42
- { user: user, port: port, proxy: proxy, logger: logger, keepalive: true, keepalive_interval: 30, keys_only: keys_only, keys: keys, key_data: key_data }.compact
57
+ { user: user, port: port, proxy: proxy, logger: logger, keepalive: true, keepalive_interval: 30, keys_only: keys_only, keys: keys, key_data: key_data, config: config }.compact
43
58
  end
44
59
 
45
60
  def to_h
@@ -16,6 +16,10 @@ class Kamal::Configuration::Sshkit
16
16
  sshkit_config.fetch("pool_idle_timeout", 900)
17
17
  end
18
18
 
19
+ def dns_retries
20
+ Integer(sshkit_config.fetch("dns_retries", 3))
21
+ end
22
+
19
23
  def to_h
20
24
  sshkit_config
21
25
  end
@@ -20,6 +20,26 @@ class Kamal::Configuration::Validator::Proxy < Kamal::Configuration::Validator
20
20
  error "Missing certificate_pem setting (required when private_key_pem is present)"
21
21
  end
22
22
  end
23
+
24
+ if run_config = config["run"]
25
+ if run_config["bind_ips"].present?
26
+ ensure_valid_bind_ips(config["bind_ips"])
27
+ end
28
+
29
+ if run_config["publish"] == false
30
+ if run_config["bind_ips"].present? || run_config["http_port"].present? || run_config["https_port"].present?
31
+ error "Cannot set http_port, https_port or bind_ips when publish is false"
32
+ end
33
+ end
34
+ end
23
35
  end
24
36
  end
37
+
38
+ private
39
+ def ensure_valid_bind_ips(bind_ips)
40
+ bind_ips.present? && bind_ips.each do |ip|
41
+ next if ip =~ Resolv::IPv4::Regex || ip =~ Resolv::IPv6::Regex
42
+ error "Invalid publish IP address: #{ip}"
43
+ end
44
+ end
25
45
  end
@@ -34,6 +34,10 @@ class Kamal::Configuration::Validator
34
34
  elsif example_value.is_a?(Array)
35
35
  if key == "arch"
36
36
  validate_array_of_or_type! value, example_value.first.class
37
+ elsif key.to_s == "config"
38
+ validate_ssh_config!(value)
39
+ elsif key.to_s == "files" || key.to_s == "directories"
40
+ validate_paths!(value)
37
41
  else
38
42
  validate_array_of! value, example_value.first.class
39
43
  end
@@ -129,6 +133,34 @@ class Kamal::Configuration::Validator
129
133
  end
130
134
  end
131
135
 
136
+ def validate_ssh_config!(config)
137
+ if config.is_a?(Array)
138
+ validate_array_of! config, String
139
+ elsif boolean?(config.class) || config.is_a?(String)
140
+ # Booleans and Strings are allowed
141
+ else
142
+ type_error(TrueClass, FalseClass, String, Array)
143
+ end
144
+ end
145
+
146
+ def validate_paths!(paths)
147
+ validate_type! paths, Array
148
+
149
+ paths.each_with_index do |path, index|
150
+ with_context(index) do
151
+ validate_type! path, String, Hash
152
+
153
+ if path.is_a?(Hash)
154
+ %w[local remote mode owner options].each do |key|
155
+ with_context(key) do
156
+ validate_type! path[key], String if path.key?(key)
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
163
+
132
164
  def validate_type!(value, *types)
133
165
  type_error(*types) unless types.any? { |type| valid_type?(value, type) }
134
166
  end
@@ -138,7 +170,8 @@ class Kamal::Configuration::Validator
138
170
  end
139
171
 
140
172
  def type_error(*expected_types)
141
- error "should be #{expected_types.map { |type| type_description(type) }.join(" or ")}"
173
+ descriptions = expected_types.map { |type| type_description(type) }.uniq
174
+ error "should be #{descriptions.join(" or ")}"
142
175
  end
143
176
 
144
177
  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
@@ -50,7 +50,7 @@ class Kamal::Configuration
50
50
 
51
51
  validate! raw_config, example: validation_yml.symbolize_keys, context: "", with: Kamal::Configuration::Validator::Configuration
52
52
 
53
- @secrets = Kamal::Secrets.new(destination: destination)
53
+ @secrets = Kamal::Secrets.new(destination: destination, secrets_path: secrets_path)
54
54
 
55
55
  # Eager load config to validate it, these are first as they have dependencies later on
56
56
  @servers = Servers.new(config: self)
@@ -77,6 +77,7 @@ class Kamal::Configuration
77
77
  ensure_one_host_for_ssl_roles
78
78
  ensure_unique_hosts_for_ssl_roles
79
79
  ensure_local_registry_remote_builder_has_ssh_url
80
+ ensure_no_conflicting_proxy_runs
80
81
  end
81
82
 
82
83
  def version=(version)
@@ -122,6 +123,14 @@ class Kamal::Configuration
122
123
  (roles + accessories).flat_map(&:hosts).uniq
123
124
  end
124
125
 
126
+ def host_roles(host)
127
+ roles.select { |role| role.hosts.include?(host) }
128
+ end
129
+
130
+ def host_accessories(host)
131
+ accessories.select { |accessory| accessory.hosts.include?(host) }
132
+ end
133
+
125
134
  def app_hosts
126
135
  roles.flat_map(&:hosts).uniq
127
136
  end
@@ -165,6 +174,11 @@ class Kamal::Configuration
165
174
  name
166
175
  end
167
176
 
177
+ def proxy_run(host)
178
+ # We validate that all the config are identical for a host
179
+ proxy_runs(host.to_s).first
180
+ end
181
+
168
182
  def repository
169
183
  [ registry.server, image ].compact.join("/")
170
184
  end
@@ -241,6 +255,10 @@ class Kamal::Configuration
241
255
  raw_config.hooks_path || ".kamal/hooks"
242
256
  end
243
257
 
258
+ def secrets_path
259
+ raw_config.secrets_path || ".kamal/secrets"
260
+ end
261
+
244
262
  def asset_path
245
263
  raw_config.asset_path
246
264
  end
@@ -374,6 +392,19 @@ class Kamal::Configuration
374
392
  true
375
393
  end
376
394
 
395
+ def ensure_no_conflicting_proxy_runs
396
+ all_hosts.each do |host|
397
+ run_configs = proxy_runs(host)
398
+ if run_configs.uniq.size > 1
399
+ raise Kamal::ConfigurationError, "Conflicting proxy run configurations for host #{host}"
400
+ end
401
+ end
402
+ end
403
+
404
+ def proxy_runs(host)
405
+ (host_roles(host) + host_accessories(host)).map(&:proxy).compact.map(&:run).compact
406
+ end
407
+
377
408
  def role_names
378
409
  raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort
379
410
  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