Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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"),
"health-check-websocket-subprotocol": proxy_config.dig("healthcheck", "websocket_subprotocol"),

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.

Yes — that's the dependency called out in the description. The proxy flags are proposed in basecamp/kamal-proxy#231 (discussion, per their CONTRIBUTING); this shouldn't merge until a release contains them and MINIMUM_VERSION is bumped here.

"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
Comment on lines +29 to +31

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.

Half right, and fixed in 4aaed5f. An empty string did skip validation and reach the proxy as --health-check-protocol=''deploy_options compacts nil, not "". Now compacted via .presence, with a test.

One correction: it wouldn't have failed at the proxy. kamal-proxy's HealthCheckConfig.Validate accepts an empty protocol explicitly (case "", HealthCheckProtocolHTTP, HealthCheckProtocolWebSocket) and treats it as http. So the flag was pointless rather than broken.


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
49 changes: 49 additions & 0 deletions test/configuration/proxy_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,55 @@ 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 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