Skip to content
Merged
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.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ def initialize(raw_config, destination: nil, version: nil, validate: true)
ensure_unique_hosts_for_ssl_roles
ensure_local_registry_remote_builder_has_ssh_url
ensure_no_conflicting_proxy_runs
ensure_valid_loadbalancer
ensure_valid_hooks_output!
end

Expand Down Expand Up @@ -432,6 +433,16 @@ def proxy_runs(host)
(host_roles(host) + host_accessories(host)).map(&:proxy).compact.map(&:run).compact
end

# `loadbalancer: true` resolves to the primary role's first host, so an
# accessories-only configuration has nothing to run it on.
def ensure_valid_loadbalancer
if proxy.loadbalancer == true && primary_role.nil?
raise Kamal::ConfigurationError, "proxy/loadbalancer: can't be enabled without servers"
end

true
end

def role_names
raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort
end
Expand Down
7 changes: 7 additions & 0 deletions lib/kamal/configuration/docs/proxy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ proxy:
# Specify a host to run the loadbalancer on. The loadbalancer will distribute requests
# to all web hosts. If not specified but multiple web hosts are configured, the first
# web host will be used as the loadbalancer host.
#
# The host may be a dedicated machine outside of `servers:`, or one of the web hosts —
# in which case the loadbalancer takes over that host's proxy container.
#
# Set `true` to always run the loadbalancer on the first host of the primary role, even
# when that role has a single host, or `false` to opt out of the automatic activation
# for multi-host primary roles.
loadbalancer: lb.example.com

# App port
Expand Down
7 changes: 6 additions & 1 deletion lib/kamal/configuration/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ def load_balancing?

def effective_loadbalancer
return false if loadbalancer == false
return primary_role_first_host if loadbalancer == true
return loadbalancer if loadbalancer.present?
return config.primary_role.hosts.first if config.primary_role && Array(config.primary_role.hosts).size > 1
return primary_role_first_host if config.primary_role && Array(config.primary_role.hosts).size > 1

nil
end
Expand Down Expand Up @@ -147,6 +148,10 @@ def merge(other)
end

private
def primary_role_first_host
config.primary_role&.hosts&.first
end

# Flags for kamal-proxy's dynamic domain source (runtime TLS hostnames).
# TLS terminates wherever these flags land, so like host/tls they are
# stripped from the per-app deploy when load balancing and re-added by
Expand Down
18 changes: 18 additions & 0 deletions lib/kamal/configuration/validator/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ def validate!
error "Specify one of 'host' or 'hosts', not both"
end

if config.key?("loadbalancer")
validate_loadbalancer! config["loadbalancer"]
end

if config["ssl"].is_a?(Hash)
if config["ssl"]["certificate_pem"].present? && config["ssl"]["private_key_pem"].blank?
error "Missing private_key_pem setting (required when certificate_pem is present)"
Expand Down Expand Up @@ -45,6 +49,20 @@ def validate!
end

private
# `true` picks the primary role's first host, `false` opts out of the
# auto-activation that kicks in for a multi-host primary role, and a string
# names the host to run the load balancer on — which may be a dedicated host
# outside `servers:`, so we only check its shape.
def validate_loadbalancer!(loadbalancer)
with_context("loadbalancer") do
return if loadbalancer == true || loadbalancer == false

unless loadbalancer.is_a?(String) && loadbalancer.present? && !loadbalancer.match?(/[\s,]/)
error "should be true, false, or a single host"
end
end
end

def validate_tls_domains!(tls_domains)
with_context("tls_domains") do
source = tls_domains["source"]
Expand Down
36 changes: 36 additions & 0 deletions test/configuration/proxy_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,42 @@ class ConfigurationProxyTest < ActiveSupport::TestCase
assert_nil config.proxy.effective_loadbalancer
end

test "effective_loadbalancer with loadbalancer true uses the primary role's first host" do
@deploy[:proxy] = { "loadbalancer" => true }
@deploy[:servers] = { "web" => [ "web1.example.com" ] }
assert_equal "web1.example.com", config.proxy.effective_loadbalancer
assert config.proxy.load_balancing?
end

test "effective_loadbalancer with loadbalancer false and multiple web hosts" do
@deploy[:proxy] = { "loadbalancer" => false }
@deploy[:servers] = { "web" => [ "web1.example.com", "web2.example.com" ] }
assert_equal false, config.proxy.effective_loadbalancer
assert_not config.proxy.load_balancing?
end

test "loadbalancer true without any servers" do
@deploy.delete(:servers)
@deploy[:accessories] = { "db" => { "image" => "mysql", "host" => "1.1.1.5" } }
@deploy[:proxy] = { "loadbalancer" => true }
exception = assert_raises(Kamal::ConfigurationError) { config }
assert_match "proxy/loadbalancer: can't be enabled without servers", exception.message
end

test "invalid loadbalancer values" do
[ 123, "", " ", "lb1.example.com,lb2.example.com", "lb.example.com extra" ].each do |value|
@deploy[:proxy] = { "loadbalancer" => value }
exception = assert_raises(Kamal::ConfigurationError, "expected #{value.inspect} to be rejected") { config.proxy }
assert_match "proxy/loadbalancer: should be true, false, or a single host", exception.message
end
end

test "loadbalancer as a list of hosts" do
@deploy[:proxy] = { "loadbalancer" => [ "lb.example.com" ] }
exception = assert_raises(Kamal::ConfigurationError) { config.proxy }
assert_match "proxy/loadbalancer: should be a string", exception.message
end

test "load_balancing? returns true when loadbalancer is present" do
@deploy[:proxy] = { "loadbalancer" => "lb.example.com" }
assert config.proxy.load_balancing?
Expand Down