Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions lib/kamal/configuration/docs/proxy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,21 @@ proxy:
# the deploy timeout, with a 5-second timeout for each request.
#
# Once the app is up, the proxy will stop hitting the healthcheck endpoint.
#
# `protocol` is `http` (the default) or `websocket`. With `websocket` the
# proxy sends a WebSocket handshake and treats `101 Switching Protocols` as
# healthy, so a target that only speaks WebSocket can be checked on the port
# it serves.
#
# `websocket_subprotocol` sets `Sec-WebSocket-Protocol`. Optional, but some
# servers will not complete the handshake without the subprotocol they speak
# -- MQTT over WebSocket, for instance, expects `mqtt`.
healthcheck:
interval: 3
path: /health
timeout: 3
protocol: websocket
websocket_subprotocol: mqtt

# Buffering
#
Expand Down
2 changes: 2 additions & 0 deletions lib/kamal/configuration/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ def deploy_options
"health-check-interval": seconds_duration(proxy_config.dig("healthcheck", "interval")),
"health-check-timeout": seconds_duration(proxy_config.dig("healthcheck", "timeout")),
"health-check-path": proxy_config.dig("healthcheck", "path"),
"health-check-protocol": proxy_config.dig("healthcheck", "protocol").presence,
"health-check-websocket-subprotocol": proxy_config.dig("healthcheck", "websocket_subprotocol").presence,
"target-timeout": seconds_duration(proxy_config["response_timeout"]),
"buffer-requests": proxy_config.fetch("buffering", { "requests": true }).fetch("requests", true),
"buffer-responses": proxy_config.fetch("buffering", { "responses": true }).fetch("responses", true),
Expand Down
14 changes: 14 additions & 0 deletions lib/kamal/configuration/validator/proxy.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class Kamal::Configuration::Validator::Proxy < Kamal::Configuration::Validator
HEALTHCHECK_PROTOCOLS = [ "http", "websocket" ].freeze

def validate!
unless config.nil?
super
Expand All @@ -21,6 +23,18 @@ def validate!
end
end

if healthcheck = config["healthcheck"]
protocol = healthcheck["protocol"]

if protocol.present? && !HEALTHCHECK_PROTOCOLS.include?(protocol)
error "Invalid healthcheck protocol: #{protocol} (must be one of #{HEALTHCHECK_PROTOCOLS.join(", ")})"
end

if healthcheck["websocket_subprotocol"].present? && protocol != "websocket"
error "Cannot set websocket_subprotocol unless the healthcheck protocol is websocket"
end
Comment on lines +33 to +35

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accurate, but I'd argue it's the house behaviour rather than a defect in this rule.

Role proxies are validated per subtree throughout, so pre-existing cross-field rules reject partial role overrides the same way. Verified side by side against this exact config shape:

MINE: REJECTED — servers/web/proxy: Cannot set websocket_subprotocol unless the healthcheck protocol is websocket
SSL:  REJECTED — servers/web/proxy: Must set a host to enable automatic SSL

That's ssl: true at the role with host inherited from the root — the same valid-after-merge configuration, rejected identically.

Deferring only this check until after the deep merge would make it behave differently from the rule directly above it in the same validator. Happy to move both if maintainers would prefer post-merge validation generally, but that seems like its own change.

end

if run_config = config["run"]
if run_config["bind_ips"].present?
ensure_valid_bind_ips(config["bind_ips"])
Expand Down
13 changes: 13 additions & 0 deletions test/configuration/accessory_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,19 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
assert_equal [ "monitoring.example.com" ], @config.accessory(:monitoring).proxy.hosts
end

test "proxy healthcheck options reach the proxy" do
@deploy[:accessories]["monitoring"]["proxy"]["healthcheck"] = {
"protocol" => "websocket",
"path" => "/mqtt",
"websocket_subprotocol" => "mqtt"
}

options = @config.accessory(:monitoring).proxy.deploy_options
assert_equal "websocket", options[:"health-check-protocol"]
assert_equal "/mqtt", options[:"health-check-path"]
assert_equal "mqtt", options[:"health-check-websocket-subprotocol"]
end

test "invalid boolean restart policy" do
@deploy[:accessories]["mysql"]["options"] = { "restart" => false }

Expand Down
55 changes: 55 additions & 0 deletions test/configuration/proxy_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,61 @@ class ConfigurationProxyTest < ActiveSupport::TestCase
end
end

test "healthcheck options are passed through to the proxy" do
@deploy[:proxy] = {
"host" => "example.com",
"healthcheck" => {
"protocol" => "websocket",
"path" => "/mqtt",
"websocket_subprotocol" => "mqtt"
}
}

options = config.proxy.deploy_options
assert_equal "websocket", options[:"health-check-protocol"]
assert_equal "/mqtt", options[:"health-check-path"]
assert_equal "mqtt", options[:"health-check-websocket-subprotocol"]
end

test "an empty healthcheck protocol passes no flag" do
@deploy[:proxy] = { "host" => "example.com", "healthcheck" => { "protocol" => "" } }

assert_not config.proxy.deploy_options.key?(:"health-check-protocol")
end

test "an unknown healthcheck protocol is rejected" do
@deploy[:proxy] = { "host" => "example.com", "healthcheck" => { "protocol" => "websockets" } }

error = assert_raises(Kamal::ConfigurationError) { config.proxy }
assert_match(/Invalid healthcheck protocol: websockets/, error.message)
end

test "a websocket subprotocol without the websocket protocol is rejected" do
[ nil, "http" ].each do |protocol|
healthcheck = { "websocket_subprotocol" => "mqtt" }
healthcheck["protocol"] = protocol if protocol
@deploy[:proxy] = { "host" => "example.com", "healthcheck" => healthcheck }

error = assert_raises(Kamal::ConfigurationError) { config.proxy }
assert_match(/websocket_subprotocol/, error.message)
end
end

test "the supported healthcheck protocols are accepted" do
[ "http", "websocket" ].each do |protocol|
@deploy[:proxy] = { "host" => "example.com", "healthcheck" => { "protocol" => protocol } }
assert_equal protocol, config.proxy.deploy_options[:"health-check-protocol"]
end
end

test "healthcheck options are omitted when unset" do
@deploy[:proxy] = { "host" => "example.com" }

options = config.proxy.deploy_options
assert_not options.key?(:"health-check-protocol")
assert_not options.key?(:"health-check-websocket-subprotocol")
end

test "ssl with certificate and no private key" do
with_test_secrets("secrets" => "CERT_PEM=certificate") do
@deploy[:proxy] = {
Expand Down