diff --git a/lib/kamal/configuration/docs/proxy.yml b/lib/kamal/configuration/docs/proxy.yml index 87e428e3c..56767035b 100644 --- a/lib/kamal/configuration/docs/proxy.yml +++ b/lib/kamal/configuration/docs/proxy.yml @@ -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 # diff --git a/lib/kamal/configuration/proxy.rb b/lib/kamal/configuration/proxy.rb index 3f2d83fad..c97132c1f 100644 --- a/lib/kamal/configuration/proxy.rb +++ b/lib/kamal/configuration/proxy.rb @@ -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), diff --git a/lib/kamal/configuration/validator/proxy.rb b/lib/kamal/configuration/validator/proxy.rb index 5dc3fd46b..49bb7c592 100644 --- a/lib/kamal/configuration/validator/proxy.rb +++ b/lib/kamal/configuration/validator/proxy.rb @@ -1,4 +1,6 @@ class Kamal::Configuration::Validator::Proxy < Kamal::Configuration::Validator + HEALTHCHECK_PROTOCOLS = [ "http", "websocket" ].freeze + def validate! unless config.nil? super @@ -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 + end + if run_config = config["run"] if run_config["bind_ips"].present? ensure_valid_bind_ips(config["bind_ips"]) diff --git a/test/configuration/accessory_test.rb b/test/configuration/accessory_test.rb index 2cca35db8..1fbcf447a 100644 --- a/test/configuration/accessory_test.rb +++ b/test/configuration/accessory_test.rb @@ -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 } diff --git a/test/configuration/proxy_test.rb b/test/configuration/proxy_test.rb index e0b328f3b..66745169b 100644 --- a/test/configuration/proxy_test.rb +++ b/test/configuration/proxy_test.rb @@ -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] = {