kamal 2.10.1 → 2.12.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/kamal/cli/accessory.rb +48 -39
  4. data/lib/kamal/cli/alias/command.rb +2 -2
  5. data/lib/kamal/cli/app.rb +57 -48
  6. data/lib/kamal/cli/base.rb +118 -17
  7. data/lib/kamal/cli/build.rb +10 -7
  8. data/lib/kamal/cli/lock.rb +5 -16
  9. data/lib/kamal/cli/main.rb +59 -53
  10. data/lib/kamal/cli/proxy.rb +9 -9
  11. data/lib/kamal/cli/prune.rb +3 -3
  12. data/lib/kamal/cli/server.rb +34 -15
  13. data/lib/kamal/cli/templates/sample_hooks/docker-setup.sample +1 -1
  14. data/lib/kamal/cli/templates/sample_hooks/post-app-boot.sample +1 -1
  15. data/lib/kamal/cli/templates/sample_hooks/post-deploy.sample +1 -1
  16. data/lib/kamal/cli/templates/sample_hooks/post-proxy-reboot.sample +1 -1
  17. data/lib/kamal/cli/templates/sample_hooks/pre-app-boot.sample +1 -1
  18. data/lib/kamal/cli/templates/sample_hooks/pre-build.sample +1 -1
  19. data/lib/kamal/cli/templates/sample_hooks/pre-proxy-reboot.sample +1 -1
  20. data/lib/kamal/cli/templates/secrets +4 -0
  21. data/lib/kamal/commander.rb +71 -17
  22. data/lib/kamal/commands/accessory.rb +3 -2
  23. data/lib/kamal/commands/app/logging.rb +1 -1
  24. data/lib/kamal/commands/app.rb +1 -1
  25. data/lib/kamal/commands/base.rb +15 -2
  26. data/lib/kamal/commands/builder/clone.rb +2 -1
  27. data/lib/kamal/commands/docker.rb +17 -1
  28. data/lib/kamal/commands/proxy.rb +1 -1
  29. data/lib/kamal/configuration/accessory.rb +13 -5
  30. data/lib/kamal/configuration/docs/alias.yml +3 -0
  31. data/lib/kamal/configuration/docs/configuration.yml +37 -2
  32. data/lib/kamal/configuration/docs/env.yml +6 -4
  33. data/lib/kamal/configuration/docs/output.yml +25 -0
  34. data/lib/kamal/configuration/docs/role.yml +1 -0
  35. data/lib/kamal/configuration/docs/ssh.yml +8 -0
  36. data/lib/kamal/configuration/output.rb +34 -0
  37. data/lib/kamal/configuration/proxy/run.rb +10 -1
  38. data/lib/kamal/configuration/role.rb +18 -6
  39. data/lib/kamal/configuration/ssh.rb +5 -1
  40. data/lib/kamal/configuration/validator.rb +29 -2
  41. data/lib/kamal/configuration.rb +41 -3
  42. data/lib/kamal/git.rb +1 -1
  43. data/lib/kamal/otel_shipper.rb +176 -0
  44. data/lib/kamal/output/base_logger.rb +29 -0
  45. data/lib/kamal/output/file_logger.rb +51 -0
  46. data/lib/kamal/output/formatter.rb +36 -0
  47. data/lib/kamal/output/otel_logger.rb +70 -0
  48. data/lib/kamal/secrets/adapters/aws_secrets_manager.rb +10 -2
  49. data/lib/kamal/secrets/adapters/passbolt.rb +1 -1
  50. data/lib/kamal/secrets.rb +1 -1
  51. data/lib/kamal/sshkit_with_ext.rb +9 -4
  52. data/lib/kamal/version.rb +1 -1
  53. metadata +23 -2
@@ -11,11 +11,11 @@ module Kamal::Commands
11
11
  end
12
12
 
13
13
  def run_over_ssh(*command, host:)
14
- "ssh#{ssh_proxy_args}#{ssh_keys_args} -t #{config.ssh.user}@#{host} -p #{config.ssh.port} '#{command.join(" ").gsub("'", "'\\\\''")}'"
14
+ "ssh#{ssh_config_args}#{ssh_proxy_args}#{ssh_keys_args} -t #{config.ssh.user}@#{host} -p #{config.ssh.port} '#{command.join(" ").gsub("'", "'\\\\''")}'"
15
15
  end
16
16
 
17
17
  def container_id_for(container_name:, only_running: false)
18
- docker :container, :ls, *("--all" unless only_running), "--filter", "name=^#{container_name}$", "--quiet"
18
+ docker :container, :ls, *("--all" unless only_running), "--filter", "'name=^#{container_name}$'", "--quiet"
19
19
  end
20
20
 
21
21
  def make_directory_for(remote_file)
@@ -100,6 +100,19 @@ module Kamal::Commands
100
100
  Kamal::Tags.from_config(config, **details)
101
101
  end
102
102
 
103
+ def ssh_config_args
104
+ case config.ssh.config
105
+ when Array
106
+ config.ssh.config.map { |file| " -F #{file}" }.join
107
+ when String
108
+ " -F #{config.ssh.config}"
109
+ when true
110
+ "" # Use default SSH config
111
+ when false
112
+ " -F /dev/null" # Ignore SSH config
113
+ end
114
+ end
115
+
103
116
  def ssh_proxy_args
104
117
  case config.ssh.proxy
105
118
  when Net::SSH::Proxy::Jump
@@ -9,7 +9,8 @@ module Kamal::Commands::Builder::Clone
9
9
  git(:fetch, :origin, path: escaped_build_directory),
10
10
  git(:reset, "--hard", Kamal::Git.revision, path: escaped_build_directory),
11
11
  git(:clean, "-fdx", path: escaped_build_directory),
12
- git(:submodule, :update, "--init", path: escaped_build_directory)
12
+ git(:submodule, :update, "--init", path: escaped_build_directory),
13
+ git(:gc, "--auto", "--quiet", path: escaped_build_directory)
13
14
  ]
14
15
  end
15
16
 
@@ -16,7 +16,23 @@ class Kamal::Commands::Docker < Kamal::Commands::Base
16
16
 
17
17
  # Do we have superuser access to install Docker and start system services?
18
18
  def superuser?
19
- [ '[ "${EUID:-$(id -u)}" -eq 0 ] || command -v sudo >/dev/null || command -v su >/dev/null' ]
19
+ [ '[ "${EUID:-$(id -u)}" -eq 0 ] || sudo -nl usermod >/dev/null' ]
20
+ end
21
+
22
+ def root?
23
+ [ '[ "${EUID:-$(id -u)}" -eq 0 ]' ]
24
+ end
25
+
26
+ def in_docker_group?
27
+ [ 'id -nG "${USER:-$(id -un)}" | grep -qw docker' ]
28
+ end
29
+
30
+ def add_to_docker_group
31
+ [ 'sudo -n usermod -aG docker "${USER:-$(id -un)}"' ]
32
+ end
33
+
34
+ def refresh_session
35
+ [ "kill -HUP $PPID" ]
20
36
  end
21
37
 
22
38
  def create_network
@@ -37,7 +37,7 @@ class Kamal::Commands::Proxy < Kamal::Commands::Base
37
37
  end
38
38
 
39
39
  def info
40
- docker :ps, "--filter", "name=^#{container_name}$"
40
+ docker :ps, "--filter", "'name=^#{container_name}$'"
41
41
  end
42
42
 
43
43
  def version
@@ -102,11 +102,11 @@ class Kamal::Configuration::Accessory
102
102
  end
103
103
 
104
104
  def option_args
105
- if args = accessory_config["options"]
106
- optionize args
107
- else
108
- []
109
- end
105
+ optionize docker_options.reject { |key, _| key.to_s == "restart" }
106
+ end
107
+
108
+ def restart_policy
109
+ restart_policy_option || "unless-stopped"
110
110
  end
111
111
 
112
112
  def cmd
@@ -173,6 +173,14 @@ class Kamal::Configuration::Accessory
173
173
  accessory_config["volumes"] || []
174
174
  end
175
175
 
176
+ def docker_options
177
+ accessory_config["options"] || {}
178
+ end
179
+
180
+ def restart_policy_option
181
+ docker_options.find { |key, _| key.to_s == "restart" }&.last
182
+ end
183
+
176
184
  def path_volumes(paths)
177
185
  paths.map do |local, config|
178
186
  Kamal::Configuration::Volume.new \
@@ -24,3 +24,6 @@ aliases:
24
24
  # Each alias is named and can only contain lowercase letters, numbers, dashes, and underscores:
25
25
  aliases:
26
26
  uname: app exec -p -q -r web "uname -a"
27
+ #
28
+ # Aliases can include a destination with the `-d` flag:
29
+ staging_deploy: deploy -d staging
@@ -85,16 +85,38 @@ asset_path: /path/to/assets
85
85
  # See https://kamal-deploy.org/docs/hooks for more information:
86
86
  hooks_path: /user_home/kamal/hooks
87
87
 
88
+ # Hook output
89
+ #
90
+ # Hook output visibility. Can be set globally or per-hook.
91
+ # CLI flags (`-v`, `-q`) override these settings.
92
+ #
93
+ # - `:quiet` - hook output is hidden
94
+ # - `:verbose` - hook output is shown
95
+ #
96
+ # With no setting, hook output follows CLI verbosity flags.
97
+ #
98
+ # Note: Failed hooks always show output in the error message regardless of setting.
99
+ #
100
+ # Global setting for all hooks:
101
+ hooks_output: :verbose
102
+ # Or per-hook settings:
103
+ hooks_output:
104
+ pre-deploy: :verbose
105
+ pre-build: :quiet
106
+
88
107
  # Secrets path
89
108
  #
90
109
  # Path to secrets, defaults to `.kamal/secrets`.
91
- # Kamal will look for `<secrets_path>-common` and `<secrets_path>` (or `<secrets_path>.<destination>` when using destinations):
110
+ # Kamal looks for `<secrets_path>-common` first and then `<secrets_path>`.
111
+ # When using destinations, it instead looks for `<secrets_path>-common` first and then
112
+ # `<secrets_path>.<destination>`. Later files override earlier ones.
92
113
  secrets_path: /user_home/kamal/secrets
93
114
 
94
115
  # Error pages
95
116
  #
96
117
  # A directory relative to the app root to find error pages for the proxy to serve.
97
- # Any files in the format 4xx.html or 5xx.html will be copied to the hosts.
118
+ # Name each page after the HTTP status code it serves, e.g. 404.html, 500.html,
119
+ # 502.html, 503.html, and 504.html.
98
120
  error_pages_path: public
99
121
 
100
122
  # Require destinations
@@ -139,6 +161,13 @@ deploy_timeout: 10
139
161
  # How long to wait for a container to drain, default 30:
140
162
  drain_timeout: 10
141
163
 
164
+ # Stop timeout
165
+ #
166
+ # How long to wait for a container to stop after SIGTERM, default is
167
+ # the drain_timeout for non-proxied roles and 10s (Docker default) for proxied roles.
168
+ # Can be overridden per role:
169
+ stop_timeout: 30
170
+
142
171
  # Run directory
143
172
  #
144
173
  # Directory to store kamal runtime files in on the host, default `.kamal`:
@@ -186,6 +215,12 @@ boot:
186
215
  logging:
187
216
  ...
188
217
 
218
+ # Output
219
+ #
220
+ # Configure output loggers (OTel, file), see kamal docs output:
221
+ output:
222
+ ...
223
+
189
224
  # Aliases
190
225
  #
191
226
  # Alias configuration, see kamal docs alias:
@@ -14,12 +14,14 @@ env:
14
14
 
15
15
  # Secrets
16
16
  #
17
- # Kamal uses dotenv to automatically load environment variables set in the `.kamal/secrets` file.
17
+ # Kamal uses dotenv to automatically load environment variables from the configured secrets files.
18
18
  #
19
- # If you are using destinations, secrets will instead be read from `.kamal/secrets.<DESTINATION>` if
20
- # it exists.
19
+ # Common secrets across all destinations can be set in `.kamal/secrets-common`. Kamal looks for
20
+ # `.kamal/secrets-common` first, then `.kamal/secrets`, with later values overriding earlier ones.
21
21
  #
22
- # Common secrets across all destinations can be set in `.kamal/secrets-common`.
22
+ # If you are using destinations, Kamal looks for `.kamal/secrets-common` first, then
23
+ # `.kamal/secrets.<destination>`. The non-destination `.kamal/secrets` file is not read when a
24
+ # destination is selected.
23
25
  #
24
26
  # This file can be used to set variables like `KAMAL_REGISTRY_PASSWORD` or database passwords.
25
27
  # You can use variable or command substitution in the secrets file.
@@ -0,0 +1,25 @@
1
+ # Output
2
+ #
3
+ # Configure where Kamal sends command output logs.
4
+
5
+ # Output options
6
+ #
7
+ # The options are specified under the output key in the configuration file.
8
+ output:
9
+
10
+ # OTel
11
+ #
12
+ # Ship deploy logs to an OpenTelemetry-compatible endpoint via OTLP HTTP.
13
+ #
14
+ # Logs are sent as OTLP log records with resource attributes derived from
15
+ # Kamal's deploy tags (service, version, performer, destination, etc.)
16
+ otel:
17
+ endpoint: http://otel-gateway:4318
18
+
19
+ # File
20
+ #
21
+ # Write deploy logs to a file on the local machine.
22
+ #
23
+ # One log file is created per deploy, named with the timestamp and command.
24
+ file:
25
+ path: /var/log/kamal/
@@ -39,6 +39,7 @@ servers:
39
39
  - 172.1.0.3
40
40
  - 172.1.0.4: experiment1
41
41
  cmd: "bin/jobs"
42
+ stop_timeout: 30
42
43
  options:
43
44
  memory: 2g
44
45
  cpus: 4
@@ -71,3 +71,11 @@ ssh:
71
71
  # /etc/ssh_config), to false ignore config files, or to a file path
72
72
  # (or array of paths) to load specific configuration. Defaults to true.
73
73
  config: [ "~/.ssh/myconfig" ]
74
+
75
+ # Forward agent
76
+ #
77
+ # Whether to forward the local SSH agent to the remote host. Defaults to
78
+ # true (sshkit's default). Set to false when connecting through a jump
79
+ # host or tunnel that does not support agent forwarding (for example,
80
+ # Cloudflare Access for Infrastructure with SSH).
81
+ forward_agent: false
@@ -0,0 +1,34 @@
1
+ class Kamal::Configuration::Output
2
+ include Kamal::Configuration::Validation
3
+
4
+ LOGGER_TYPES = {
5
+ "otel" => "Kamal::Output::OtelLogger",
6
+ "file" => "Kamal::Output::FileLogger"
7
+ }
8
+
9
+ attr_reader :output_config, :loggers
10
+
11
+ def initialize(config:)
12
+ @config = config
13
+ @output_config = config.raw_config.output || {}
14
+ validate! @output_config unless @output_config.empty?
15
+ @loggers = build_loggers
16
+ end
17
+
18
+ def enabled?
19
+ output_config.present?
20
+ end
21
+
22
+ def to_h
23
+ output_config
24
+ end
25
+
26
+ private
27
+ def build_loggers
28
+ output_config.filter_map do |key, settings|
29
+ if (klass_name = LOGGER_TYPES[key])
30
+ klass_name.constantize.build(settings: settings || {}, config: @config)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -1,5 +1,5 @@
1
1
  class Kamal::Configuration::Proxy::Run
2
- MINIMUM_VERSION = "v0.9.0"
2
+ MINIMUM_VERSION = "v0.9.2"
3
3
  DEFAULT_HTTP_PORT = 80
4
4
  DEFAULT_HTTPS_PORT = 443
5
5
  DEFAULT_LOG_MAX_SIZE = "10m"
@@ -131,6 +131,15 @@ class Kamal::Configuration::Proxy::Run
131
131
  File.join apps_container_directory, config.service_and_destination
132
132
  end
133
133
 
134
+ def ==(other)
135
+ other.is_a?(self.class) && run_config == other.run_config
136
+ end
137
+ alias_method :eql?, :==
138
+
139
+ def hash
140
+ run_config.hash
141
+ end
142
+
134
143
  private
135
144
  def format_bind_ip(ip)
136
145
  # Ensure IPv6 address inside square brackets - e.g. [::1]
@@ -44,11 +44,11 @@ class Kamal::Configuration::Role
44
44
  end
45
45
 
46
46
  def option_args
47
- if args = specializations["options"]
48
- optionize args
49
- else
50
- []
51
- end
47
+ optionize docker_options.reject { |key, _| key.to_s == "restart" }
48
+ end
49
+
50
+ def restart_policy
51
+ restart_policy_option || "unless-stopped"
52
52
  end
53
53
 
54
54
  def labels
@@ -81,11 +81,15 @@ class Kamal::Configuration::Role
81
81
 
82
82
  def stop_args
83
83
  # When deploying with the proxy, kamal-proxy will drain request before returning so we don't need to wait.
84
- timeout = running_proxy? ? nil : config.drain_timeout
84
+ timeout = stop_timeout || (running_proxy? ? nil : config.drain_timeout)
85
85
 
86
86
  [ *argumentize("-t", timeout) ]
87
87
  end
88
88
 
89
+ def stop_timeout
90
+ specializations["stop_timeout"] || config.stop_timeout
91
+ end
92
+
89
93
  def env(host)
90
94
  @envs ||= {}
91
95
  @envs[host] ||= [ config.env, specialized_env, *env_tags(host).map(&:env) ].reduce(:merge)
@@ -217,6 +221,14 @@ class Kamal::Configuration::Role
217
221
  @role_config ||= config.raw_config.servers.is_a?(Array) ? {} : config.raw_config.servers[name]
218
222
  end
219
223
 
224
+ def docker_options
225
+ specializations["options"] || {}
226
+ end
227
+
228
+ def restart_policy_option
229
+ docker_options.find { |key, _| key.to_s == "restart" }&.last
230
+ end
231
+
220
232
  def custom_labels
221
233
  Hash.new.tap do |labels|
222
234
  labels.merge!(config.labels) if config.labels.present?
@@ -53,8 +53,12 @@ class Kamal::Configuration::Ssh
53
53
  ssh_config["config"]
54
54
  end
55
55
 
56
+ def forward_agent
57
+ ssh_config["forward_agent"]
58
+ end
59
+
56
60
  def options
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
61
+ { 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, forward_agent: forward_agent }.compact
58
62
  end
59
63
 
60
64
  def to_h
@@ -29,6 +29,8 @@ class Kamal::Configuration::Validator
29
29
  end
30
30
  elsif key.to_s == "ssl"
31
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)
@@ -161,6 +163,19 @@ class Kamal::Configuration::Validator
161
163
  end
162
164
  end
163
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
+
164
179
  def validate_type!(value, *types)
165
180
  type_error(*types) unless types.any? { |type| valid_type?(value, type) }
166
181
  end
@@ -217,8 +232,20 @@ class Kamal::Configuration::Validator
217
232
  end
218
233
 
219
234
  def validate_docker_options!(options)
220
- if options
221
- error "Cannot set restart policy in docker options, unless-stopped is required" if options["restart"]
235
+ if restart_policy = options&.find { |key, _| key.to_s == "restart" }
236
+ validate_restart_policy!(restart_policy.last)
237
+ end
238
+ end
239
+
240
+ def validate_restart_policy!(restart_policy)
241
+ with_context("options/restart") do
242
+ unless restart_policy.is_a?(String)
243
+ error %(should be a string. Use "no" to disable restarts)
244
+ end
245
+
246
+ unless restart_policy.match?(/\A(?:no|always|unless-stopped|on-failure(?::\d+)?)\z/)
247
+ error "should be no, always, unless-stopped, on-failure, or on-failure:N"
248
+ end
222
249
  end
223
250
  end
224
251
  end
@@ -6,11 +6,13 @@ require "erb"
6
6
  require "net/ssh/proxy/jump"
7
7
 
8
8
  class Kamal::Configuration
9
+ HOOKS_OUTPUT_LEVELS = [ :quiet, :verbose ].freeze
10
+
9
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
13
- attr_reader :accessories, :aliases, :boot, :builder, :env, :logging, :proxy, :proxy_boot, :servers, :ssh, :sshkit, :registry
15
+ attr_reader :accessories, :aliases, :boot, :builder, :env, :logging, :output, :proxy, :proxy_boot, :servers, :ssh, :sshkit, :registry
14
16
 
15
17
  include Validation
16
18
 
@@ -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
@@ -63,6 +71,7 @@ class Kamal::Configuration
63
71
  @env = Env.new(config: @raw_config.env || {}, secrets: secrets)
64
72
 
65
73
  @logging = Logging.new(logging_config: @raw_config.logging)
74
+ @output = Output.new(config: self)
66
75
  @proxy = Proxy.new(config: self, proxy_config: @raw_config.proxy, secrets: secrets)
67
76
  @proxy_boot = Proxy::Boot.new(config: self)
68
77
  @ssh = Ssh.new(config: self)
@@ -78,6 +87,7 @@ class Kamal::Configuration
78
87
  ensure_unique_hosts_for_ssl_roles
79
88
  ensure_local_registry_remote_builder_has_ssh_url
80
89
  ensure_no_conflicting_proxy_runs
90
+ ensure_valid_hooks_output!
81
91
  end
82
92
 
83
93
  def version=(version)
@@ -231,6 +241,10 @@ class Kamal::Configuration
231
241
  raw_config.drain_timeout || 30
232
242
  end
233
243
 
244
+ def stop_timeout
245
+ raw_config.stop_timeout
246
+ end
247
+
234
248
  def run_directory
235
249
  ".kamal"
236
250
  end
@@ -279,6 +293,15 @@ class Kamal::Configuration
279
293
  env_tags.detect { |t| t.name == name.to_s }
280
294
  end
281
295
 
296
+ def hooks_output_for(hook)
297
+ case raw_config.hooks_output
298
+ when Symbol, String
299
+ raw_config.hooks_output.to_sym
300
+ when Hash
301
+ raw_config.hooks_output[hook]&.to_sym
302
+ end
303
+ end
304
+
282
305
  def to_h
283
306
  {
284
307
  roles: role_names,
@@ -409,6 +432,21 @@ class Kamal::Configuration
409
432
  raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort
410
433
  end
411
434
 
435
+ def ensure_valid_hooks_output!
436
+ case raw_config.hooks_output
437
+ when Symbol, String
438
+ validate_hooks_output_level!(raw_config.hooks_output.to_sym)
439
+ when Hash
440
+ raw_config.hooks_output.each { |hook, level| validate_hooks_output_level!(level.to_sym, hook) }
441
+ end
442
+ end
443
+
444
+ def validate_hooks_output_level!(level, hook = nil)
445
+ return if HOOKS_OUTPUT_LEVELS.include?(level)
446
+ context = hook ? " for hook '#{hook}'" : ""
447
+ raise Kamal::ConfigurationError, "Invalid hooks_output '#{level}'#{context}, must be one of: #{HOOKS_OUTPUT_LEVELS.join(', ')}"
448
+ end
449
+
412
450
  def git_version
413
451
  @git_version ||=
414
452
  if Kamal::Git.used?
data/lib/kamal/git.rb CHANGED
@@ -6,7 +6,7 @@ module Kamal::Git
6
6
  end
7
7
 
8
8
  def user_name
9
- `git config user.name`.strip
9
+ `git config user.name`.force_encoding(Encoding::UTF_8).strip
10
10
  end
11
11
 
12
12
  def email