kamal 1.6.0 → 1.7.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/lib/kamal/cli/accessory.rb +5 -3
  3. data/lib/kamal/cli/app.rb +6 -3
  4. data/lib/kamal/cli/build.rb +13 -10
  5. data/lib/kamal/cli/healthcheck/poller.rb +2 -2
  6. data/lib/kamal/cli/main.rb +14 -2
  7. data/lib/kamal/cli/registry.rb +9 -10
  8. data/lib/kamal/cli/templates/sample_hooks/docker-setup.sample +1 -1
  9. data/lib/kamal/cli/traefik.rb +5 -3
  10. data/lib/kamal/cli.rb +1 -1
  11. data/lib/kamal/commands/accessory.rb +4 -4
  12. data/lib/kamal/commands/app/logging.rb +4 -4
  13. data/lib/kamal/commands/builder/base.rb +13 -0
  14. data/lib/kamal/commands/builder/multiarch/remote.rb +10 -0
  15. data/lib/kamal/commands/builder/multiarch.rb +4 -0
  16. data/lib/kamal/commands/builder/native/cached.rb +10 -1
  17. data/lib/kamal/commands/builder/native/remote.rb +8 -0
  18. data/lib/kamal/commands/builder.rb +17 -11
  19. data/lib/kamal/commands/registry.rb +4 -13
  20. data/lib/kamal/commands/traefik.rb +8 -47
  21. data/lib/kamal/configuration/accessory.rb +30 -41
  22. data/lib/kamal/configuration/boot.rb +9 -4
  23. data/lib/kamal/configuration/builder.rb +33 -33
  24. data/lib/kamal/configuration/docs/accessory.yml +90 -0
  25. data/lib/kamal/configuration/docs/boot.yml +19 -0
  26. data/lib/kamal/configuration/docs/builder.yml +107 -0
  27. data/lib/kamal/configuration/docs/configuration.yml +157 -0
  28. data/lib/kamal/configuration/docs/env.yml +72 -0
  29. data/lib/kamal/configuration/docs/healthcheck.yml +59 -0
  30. data/lib/kamal/configuration/docs/logging.yml +21 -0
  31. data/lib/kamal/configuration/docs/registry.yml +49 -0
  32. data/lib/kamal/configuration/docs/role.yml +52 -0
  33. data/lib/kamal/configuration/docs/servers.yml +27 -0
  34. data/lib/kamal/configuration/docs/ssh.yml +46 -0
  35. data/lib/kamal/configuration/docs/sshkit.yml +23 -0
  36. data/lib/kamal/configuration/docs/traefik.yml +62 -0
  37. data/lib/kamal/configuration/env/tag.rb +1 -1
  38. data/lib/kamal/configuration/env.rb +10 -14
  39. data/lib/kamal/configuration/healthcheck.rb +63 -0
  40. data/lib/kamal/configuration/logging.rb +33 -0
  41. data/lib/kamal/configuration/registry.rb +31 -0
  42. data/lib/kamal/configuration/role.rb +53 -65
  43. data/lib/kamal/configuration/servers.rb +18 -0
  44. data/lib/kamal/configuration/ssh.rb +11 -8
  45. data/lib/kamal/configuration/sshkit.rb +9 -7
  46. data/lib/kamal/configuration/traefik.rb +60 -0
  47. data/lib/kamal/configuration/validation.rb +27 -0
  48. data/lib/kamal/configuration/validator/accessory.rb +9 -0
  49. data/lib/kamal/configuration/validator/builder.rb +9 -0
  50. data/lib/kamal/configuration/validator/env.rb +54 -0
  51. data/lib/kamal/configuration/validator/registry.rb +25 -0
  52. data/lib/kamal/configuration/validator/role.rb +11 -0
  53. data/lib/kamal/configuration/validator/servers.rb +7 -0
  54. data/lib/kamal/configuration/validator.rb +140 -0
  55. data/lib/kamal/configuration.rb +41 -66
  56. data/lib/kamal/version.rb +1 -1
  57. data/lib/kamal.rb +2 -0
  58. metadata +49 -3
@@ -0,0 +1,33 @@
1
+ class Kamal::Configuration::Logging
2
+ delegate :optionize, :argumentize, to: Kamal::Utils
3
+
4
+ include Kamal::Configuration::Validation
5
+
6
+ attr_reader :logging_config
7
+
8
+ def initialize(logging_config:, context: "logging")
9
+ @logging_config = logging_config || {}
10
+ validate! @logging_config, context: context
11
+ end
12
+
13
+ def driver
14
+ logging_config["driver"]
15
+ end
16
+
17
+ def options
18
+ logging_config.fetch("options", {})
19
+ end
20
+
21
+ def merge(other)
22
+ self.class.new logging_config: logging_config.deep_merge(other.logging_config)
23
+ end
24
+
25
+ def args
26
+ if driver.present? || options.present?
27
+ optionize({ "log-driver" => driver }.compact) +
28
+ argumentize("--log-opt", options)
29
+ else
30
+ argumentize("--log-opt", { "max-size" => "10m" })
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,31 @@
1
+ class Kamal::Configuration::Registry
2
+ include Kamal::Configuration::Validation
3
+
4
+ attr_reader :registry_config
5
+
6
+ def initialize(config:)
7
+ @registry_config = config.raw_config.registry || {}
8
+ validate! registry_config, with: Kamal::Configuration::Validator::Registry
9
+ end
10
+
11
+ def server
12
+ registry_config["server"]
13
+ end
14
+
15
+ def username
16
+ lookup("username")
17
+ end
18
+
19
+ def password
20
+ lookup("password")
21
+ end
22
+
23
+ private
24
+ def lookup(key)
25
+ if registry_config[key].is_a?(Array)
26
+ ENV.fetch(registry_config[key].first).dup
27
+ else
28
+ registry_config[key]
29
+ end
30
+ end
31
+ end
@@ -1,13 +1,33 @@
1
1
  class Kamal::Configuration::Role
2
+ include Kamal::Configuration::Validation
3
+
2
4
  CORD_FILE = "cord"
3
5
  delegate :argumentize, :optionize, to: Kamal::Utils
4
6
 
5
- attr_accessor :name
7
+ attr_reader :name, :config, :specialized_env, :specialized_logging, :specialized_healthcheck
8
+
6
9
  alias to_s name
7
10
 
8
11
  def initialize(name, config:)
9
12
  @name, @config = name.inquiry, config
10
- @tagged_hosts ||= extract_tagged_hosts_from_config
13
+ validate! \
14
+ specializations,
15
+ example: validation_yml["servers"]["workers"],
16
+ context: "servers/#{name}",
17
+ with: Kamal::Configuration::Validator::Role
18
+
19
+ @specialized_env = Kamal::Configuration::Env.new \
20
+ config: specializations.fetch("env", {}),
21
+ secrets_file: File.join(config.host_env_directory, "roles", "#{container_prefix}.env"),
22
+ context: "servers/#{name}/env"
23
+
24
+ @specialized_logging = Kamal::Configuration::Logging.new \
25
+ logging_config: specializations.fetch("logging", {}),
26
+ context: "servers/#{name}/logging"
27
+
28
+ @specialized_healthcheck = Kamal::Configuration::Healthcheck.new \
29
+ healthcheck_config: specializations.fetch("healthcheck", {}),
30
+ context: "servers/#{name}/healthcheck"
11
31
  end
12
32
 
13
33
  def primary_host
@@ -43,21 +63,17 @@ class Kamal::Configuration::Role
43
63
  end
44
64
 
45
65
  def logging_args
46
- args = config.logging || {}
47
- args.deep_merge!(specializations["logging"]) if specializations["logging"].present?
66
+ logging.args
67
+ end
48
68
 
49
- if args.any?
50
- optionize({ "log-driver" => args["driver"] }.compact) +
51
- argumentize("--log-opt", args["options"])
52
- else
53
- config.logging_args
54
- end
69
+ def logging
70
+ @logging ||= config.logging.merge(specialized_logging)
55
71
  end
56
72
 
57
73
 
58
74
  def env(host)
59
75
  @envs ||= {}
60
- @envs[host] ||= [ base_env, specialized_env, *env_tags(host).map(&:env) ].reduce(:merge)
76
+ @envs[host] ||= [ config.env, specialized_env, *env_tags(host).map(&:env) ].reduce(:merge)
61
77
  end
62
78
 
63
79
  def env_args(host)
@@ -70,28 +86,29 @@ class Kamal::Configuration::Role
70
86
 
71
87
 
72
88
  def health_check_args(cord: true)
73
- if health_check_cmd.present?
89
+ if running_traefik? || healthcheck.set_port_or_path?
74
90
  if cord && uses_cord?
75
- optionize({ "health-cmd" => health_check_cmd_with_cord, "health-interval" => health_check_interval })
91
+ optionize({ "health-cmd" => health_check_cmd_with_cord, "health-interval" => healthcheck.interval })
76
92
  .concat(cord_volume.docker_args)
77
93
  else
78
- optionize({ "health-cmd" => health_check_cmd, "health-interval" => health_check_interval })
94
+ optionize({ "health-cmd" => healthcheck.cmd, "health-interval" => healthcheck.interval })
79
95
  end
80
96
  else
81
97
  []
82
98
  end
83
99
  end
84
100
 
85
- def health_check_cmd
86
- health_check_options["cmd"] || http_health_check(port: health_check_options["port"], path: health_check_options["path"])
101
+ def healthcheck
102
+ @healthcheck ||=
103
+ if running_traefik?
104
+ config.healthcheck.merge(specialized_healthcheck)
105
+ else
106
+ specialized_healthcheck
107
+ end
87
108
  end
88
109
 
89
110
  def health_check_cmd_with_cord
90
- "(#{health_check_cmd}) && (stat #{cord_container_file} > /dev/null || exit 1)"
91
- end
92
-
93
- def health_check_interval
94
- health_check_options["interval"] || "1s"
111
+ "(#{healthcheck.cmd}) && (stat #{cord_container_file} > /dev/null || exit 1)"
95
112
  end
96
113
 
97
114
 
@@ -109,7 +126,7 @@ class Kamal::Configuration::Role
109
126
 
110
127
 
111
128
  def uses_cord?
112
- running_traefik? && cord_volume && health_check_cmd.present?
129
+ running_traefik? && cord_volume && healthcheck.cmd.present?
113
130
  end
114
131
 
115
132
  def cord_host_directory
@@ -117,7 +134,7 @@ class Kamal::Configuration::Role
117
134
  end
118
135
 
119
136
  def cord_volume
120
- if (cord = health_check_options["cord"])
137
+ if (cord = healthcheck.cord)
121
138
  @cord_volume ||= Kamal::Configuration::Volume.new \
122
139
  host_path: File.join(config.run_directory, "cords", [ container_prefix, config.run_id ].join("-")),
123
140
  container_path: cord
@@ -170,30 +187,24 @@ class Kamal::Configuration::Role
170
187
  end
171
188
 
172
189
  private
173
- attr_accessor :config, :tagged_hosts
174
-
175
- def extract_tagged_hosts_from_config
190
+ def tagged_hosts
176
191
  {}.tap do |tagged_hosts|
177
192
  extract_hosts_from_config.map do |host_config|
178
193
  if host_config.is_a?(Hash)
179
- raise ArgumentError, "Multiple hosts found: #{host_config.inspect}" unless host_config.size == 1
180
-
181
194
  host, tags = host_config.first
182
195
  tagged_hosts[host] = Array(tags)
183
- elsif host_config.is_a?(String) || host_config.is_a?(Symbol)
196
+ elsif host_config.is_a?(String)
184
197
  tagged_hosts[host_config] = []
185
- else
186
- raise ArgumentError, "Invalid host config: #{host_config.inspect}"
187
198
  end
188
199
  end
189
200
  end
190
201
  end
191
202
 
192
203
  def extract_hosts_from_config
193
- if config.servers.is_a?(Array)
194
- config.servers
204
+ if config.raw_config.servers.is_a?(Array)
205
+ config.raw_config.servers
195
206
  else
196
- servers = config.servers[name]
207
+ servers = config.raw_config.servers[name]
197
208
  servers.is_a?(Array) ? servers : Array(servers["hosts"])
198
209
  end
199
210
  end
@@ -202,6 +213,14 @@ class Kamal::Configuration::Role
202
213
  { "service" => config.service, "role" => name, "destination" => config.destination }
203
214
  end
204
215
 
216
+ def specializations
217
+ if config.raw_config.servers.is_a?(Array) || config.raw_config.servers[name].is_a?(Array)
218
+ {}
219
+ else
220
+ config.raw_config.servers[name]
221
+ end
222
+ end
223
+
205
224
  def traefik_labels
206
225
  if running_traefik?
207
226
  {
@@ -229,35 +248,4 @@ class Kamal::Configuration::Role
229
248
  labels.merge!(specializations["labels"]) if specializations["labels"].present?
230
249
  end
231
250
  end
232
-
233
- def specializations
234
- if config.servers.is_a?(Array) || config.servers[name].is_a?(Array)
235
- {}
236
- else
237
- config.servers[name].except("hosts")
238
- end
239
- end
240
-
241
- def specialized_env
242
- Kamal::Configuration::Env.from_config config: specializations.fetch("env", {})
243
- end
244
-
245
- # Secrets are stored in an array, which won't merge by default, so have to do it by hand.
246
- def base_env
247
- Kamal::Configuration::Env.from_config \
248
- config: config.env,
249
- secrets_file: File.join(config.host_env_directory, "roles", "#{container_prefix}.env")
250
- end
251
-
252
- def http_health_check(port:, path:)
253
- "curl -f #{URI.join("http://localhost:#{port}", path)} || exit 1" if path.present? || port.present?
254
- end
255
-
256
- def health_check_options
257
- @health_check_options ||= begin
258
- options = specializations["healthcheck"] || {}
259
- options = config.healthcheck.merge(options) if running_traefik?
260
- options
261
- end
262
- end
263
251
  end
@@ -0,0 +1,18 @@
1
+ class Kamal::Configuration::Servers
2
+ include Kamal::Configuration::Validation
3
+
4
+ attr_reader :config, :servers_config, :roles
5
+
6
+ def initialize(config:)
7
+ @config = config
8
+ @servers_config = config.raw_config.servers
9
+ validate! servers_config, with: Kamal::Configuration::Validator::Servers
10
+
11
+ @roles = role_names.map { |role_name| Kamal::Configuration::Role.new role_name, config: config }
12
+ end
13
+
14
+ private
15
+ def role_names
16
+ servers_config.is_a?(Array) ? [ "web" ] : servers_config.keys.sort
17
+ end
18
+ end
@@ -1,22 +1,27 @@
1
1
  class Kamal::Configuration::Ssh
2
2
  LOGGER = ::Logger.new(STDERR)
3
3
 
4
+ include Kamal::Configuration::Validation
5
+
6
+ attr_reader :ssh_config
7
+
4
8
  def initialize(config:)
5
- @config = config.raw_config.ssh || {}
9
+ @ssh_config = config.raw_config.ssh || {}
10
+ validate! ssh_config
6
11
  end
7
12
 
8
13
  def user
9
- config.fetch("user", "root")
14
+ ssh_config.fetch("user", "root")
10
15
  end
11
16
 
12
17
  def port
13
- config.fetch("port", 22)
18
+ ssh_config.fetch("port", 22)
14
19
  end
15
20
 
16
21
  def proxy
17
- if (proxy = config["proxy"])
22
+ if (proxy = ssh_config["proxy"])
18
23
  Net::SSH::Proxy::Jump.new(proxy.include?("@") ? proxy : "root@#{proxy}")
19
- elsif (proxy_command = config["proxy_command"])
24
+ elsif (proxy_command = ssh_config["proxy_command"])
20
25
  Net::SSH::Proxy::Command.new(proxy_command)
21
26
  end
22
27
  end
@@ -30,13 +35,11 @@ class Kamal::Configuration::Ssh
30
35
  end
31
36
 
32
37
  private
33
- attr_accessor :config
34
-
35
38
  def logger
36
39
  LOGGER.tap { |logger| logger.level = log_level }
37
40
  end
38
41
 
39
42
  def log_level
40
- config.fetch("log_level", :fatal)
43
+ ssh_config.fetch("log_level", :fatal)
41
44
  end
42
45
  end
@@ -1,20 +1,22 @@
1
1
  class Kamal::Configuration::Sshkit
2
+ include Kamal::Configuration::Validation
3
+
4
+ attr_reader :sshkit_config
5
+
2
6
  def initialize(config:)
3
- @options = config.raw_config.sshkit || {}
7
+ @sshkit_config = config.raw_config.sshkit || {}
8
+ validate! sshkit_config
4
9
  end
5
10
 
6
11
  def max_concurrent_starts
7
- options.fetch("max_concurrent_starts", 30)
12
+ sshkit_config.fetch("max_concurrent_starts", 30)
8
13
  end
9
14
 
10
15
  def pool_idle_timeout
11
- options.fetch("pool_idle_timeout", 900)
16
+ sshkit_config.fetch("pool_idle_timeout", 900)
12
17
  end
13
18
 
14
19
  def to_h
15
- options
20
+ sshkit_config
16
21
  end
17
-
18
- private
19
- attr_accessor :options
20
22
  end
@@ -0,0 +1,60 @@
1
+ class Kamal::Configuration::Traefik
2
+ DEFAULT_IMAGE = "traefik:v2.10"
3
+ CONTAINER_PORT = 80
4
+ DEFAULT_ARGS = {
5
+ "log.level" => "DEBUG"
6
+ }
7
+ DEFAULT_LABELS = {
8
+ # These ensure we serve a 502 rather than a 404 if no containers are available
9
+ "traefik.http.routers.catchall.entryPoints" => "http",
10
+ "traefik.http.routers.catchall.rule" => "PathPrefix(`/`)",
11
+ "traefik.http.routers.catchall.service" => "unavailable",
12
+ "traefik.http.routers.catchall.priority" => 1,
13
+ "traefik.http.services.unavailable.loadbalancer.server.port" => "0"
14
+ }
15
+
16
+ include Kamal::Configuration::Validation
17
+
18
+ attr_reader :config, :traefik_config
19
+
20
+ def initialize(config:)
21
+ @config = config
22
+ @traefik_config = config.raw_config.traefik || {}
23
+ validate! traefik_config
24
+ end
25
+
26
+ def publish?
27
+ traefik_config["publish"] != false
28
+ end
29
+
30
+ def labels
31
+ DEFAULT_LABELS.merge(traefik_config["labels"] || {})
32
+ end
33
+
34
+ def env
35
+ Kamal::Configuration::Env.new \
36
+ config: traefik_config.fetch("env", {}),
37
+ secrets_file: File.join(config.host_env_directory, "traefik", "traefik.env"),
38
+ context: "traefik/env"
39
+ end
40
+
41
+ def host_port
42
+ traefik_config.fetch("host_port", CONTAINER_PORT)
43
+ end
44
+
45
+ def options
46
+ traefik_config.fetch("options", {})
47
+ end
48
+
49
+ def port
50
+ "#{host_port}:#{CONTAINER_PORT}"
51
+ end
52
+
53
+ def args
54
+ DEFAULT_ARGS.merge(traefik_config.fetch("args", {}))
55
+ end
56
+
57
+ def image
58
+ traefik_config.fetch("image", DEFAULT_IMAGE)
59
+ end
60
+ end
@@ -0,0 +1,27 @@
1
+ require "yaml"
2
+ require "active_support/inflector"
3
+
4
+ module Kamal::Configuration::Validation
5
+ extend ActiveSupport::Concern
6
+
7
+ class_methods do
8
+ def validation_doc
9
+ @validation_doc ||= File.read(File.join(File.dirname(__FILE__), "docs", "#{validation_config_key}.yml"))
10
+ end
11
+
12
+ def validation_config_key
13
+ @validation_config_key ||= name.demodulize.underscore
14
+ end
15
+ end
16
+
17
+ def validate!(config, example: nil, context: nil, with: Kamal::Configuration::Validator)
18
+ context ||= self.class.validation_config_key
19
+ example ||= validation_yml[self.class.validation_config_key]
20
+
21
+ with.new(config, example: example, context: context).validate!
22
+ end
23
+
24
+ def validation_yml
25
+ @validation_yml ||= YAML.load(self.class.validation_doc)
26
+ end
27
+ end
@@ -0,0 +1,9 @@
1
+ class Kamal::Configuration::Validator::Accessory < Kamal::Configuration::Validator
2
+ def validate!
3
+ super
4
+
5
+ if (config.keys & [ "host", "hosts", "roles" ]).size != 1
6
+ error "specify one of `host`, `hosts` or `roles`"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ class Kamal::Configuration::Validator::Builder < Kamal::Configuration::Validator
2
+ def validate!
3
+ super
4
+
5
+ if config["cache"] && config["cache"]["type"]
6
+ error "Invalid cache type: #{config["cache"]["type"]}" unless [ "gha", "registry" ].include?(config["cache"]["type"])
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,54 @@
1
+ class Kamal::Configuration::Validator::Env < Kamal::Configuration::Validator
2
+ SPECIAL_KEYS = [ "clear", "secret", "tags" ]
3
+
4
+ def validate!
5
+ if known_keys.any?
6
+ validate_complex_env!
7
+ else
8
+ validate_simple_env!
9
+ end
10
+ end
11
+
12
+ private
13
+ def validate_simple_env!
14
+ validate_hash_of!(config, String)
15
+ end
16
+
17
+ def validate_complex_env!
18
+ unknown_keys_error unknown_keys if unknown_keys.any?
19
+
20
+ with_context("clear") { validate_hash_of!(config["clear"], String) } if config.key?("clear")
21
+ with_context("secret") { validate_array_of!(config["secret"], String) } if config.key?("secret")
22
+ validate_tags! if config.key?("tags")
23
+ end
24
+
25
+ def known_keys
26
+ @known_keys ||= config.keys & SPECIAL_KEYS
27
+ end
28
+
29
+ def unknown_keys
30
+ @unknown_keys ||= config.keys - SPECIAL_KEYS
31
+ end
32
+
33
+ def validate_tags!
34
+ if context == "env"
35
+ with_context("tags") do
36
+ validate_type! config["tags"], Hash
37
+
38
+ config["tags"].each do |tag, value|
39
+ with_context(tag) do
40
+ validate_type! value, Hash
41
+
42
+ Kamal::Configuration::Validator::Env.new(
43
+ value,
44
+ example: example["tags"].values[1],
45
+ context: context
46
+ ).validate!
47
+ end
48
+ end
49
+ end
50
+ else
51
+ error "tags are only allowed in the root env"
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,25 @@
1
+ class Kamal::Configuration::Validator::Registry < Kamal::Configuration::Validator
2
+ STRING_OR_ONE_ITEM_ARRAY_KEYS = [ "username", "password" ]
3
+
4
+ def validate!
5
+ validate_against_example! \
6
+ config.except(*STRING_OR_ONE_ITEM_ARRAY_KEYS),
7
+ example.except(*STRING_OR_ONE_ITEM_ARRAY_KEYS)
8
+
9
+ validate_string_or_one_item_array! "username"
10
+ validate_string_or_one_item_array! "password"
11
+ end
12
+
13
+ private
14
+ def validate_string_or_one_item_array!(key)
15
+ with_context(key) do
16
+ value = config[key]
17
+
18
+ error "is required" unless value.present?
19
+
20
+ unless value.is_a?(String) || (value.is_a?(Array) && value.size == 1 && value.first.is_a?(String))
21
+ error "should be a string or an array with one string (for secret lookup)"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,11 @@
1
+ class Kamal::Configuration::Validator::Role < Kamal::Configuration::Validator
2
+ def validate!
3
+ validate_type! config, Array, Hash
4
+
5
+ if config.is_a?(Array)
6
+ validate_servers! "servers", config
7
+ else
8
+ super
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ class Kamal::Configuration::Validator::Servers < Kamal::Configuration::Validator
2
+ def validate!
3
+ validate_type! config, Array, Hash
4
+
5
+ validate_servers! config if config.is_a?(Array)
6
+ end
7
+ end