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
4 changes: 4 additions & 0 deletions lib/kamal/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ def roles
servers.roles
end

def any_service_use_proxy_idle?
(roles + accessories).any? { |service| service.proxy&.idle? }
end

def role(name)
roles.detect { |r| r.name == name.to_s }
end
Expand Down
14 changes: 14 additions & 0 deletions lib/kamal/configuration/docs/proxy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,19 @@ proxy:
- X-Request-ID
- X-Request-Start

# Idle
#
# Stop the application container after a period without requests and start it
# again when the next request arrives. Both values are in seconds.
#
# Enabling idle access automatically mounts the Docker socket into kamal-proxy
# and adds the socket's numeric group ID to the container. This grants the proxy
# control over Docker on the host, so only enable it in environments where the
# proxy container is trusted with that access.
idle:
timeout: 300 # Stop the container after 5 minutes without requests
wake_timeout: 30 # Maximum time to wait for the container to become healthy

# Run configuration
#
# These options are used when booting the proxy container.
Expand All @@ -158,6 +171,7 @@ proxy:
metrics_port: 9090 # Port for Prometheus metrics
debug: true # Debug logging (default: false)
log_max_size: "30m" # Maximum log file size (default: "10m")
docker_socket: /var/run/docker.sock # Docker socket used by idle mode (default shown)
publish: false # Publish ports to the host (default: true)
bind_ips: # List of IPs to bind to when publishing ports
- 0.0.0.0
Expand Down
10 changes: 9 additions & 1 deletion lib/kamal/configuration/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,19 @@ def initialize(config:, proxy_config:, role_name: nil, secrets:, context: "proxy
@role_name = role_name
@secrets = secrets
validate! @proxy_config, with: Kamal::Configuration::Validator::Proxy, context: context
@run = Kamal::Configuration::Proxy::Run.new(config, run_config: @proxy_config["run"], context: "#{context}/run") if @proxy_config && @proxy_config["run"].present?
if @proxy_config["run"].present? || idle?
@run = Kamal::Configuration::Proxy::Run.new(config, run_config: @proxy_config["run"] || {}, context: "#{context}/run")
end
end

def app_port
proxy_config.fetch("app_port", 80)
end

def idle?
proxy_config.dig("idle", "timeout").present?
end

def ssl?
proxy_config.fetch("ssl", false)
end
Expand Down Expand Up @@ -90,6 +96,8 @@ def deploy_options
"tls-redirect": proxy_config.dig("ssl_redirect"),
"log-request-header": proxy_config.dig("logging", "request_headers") || DEFAULT_LOG_REQUEST_HEADERS,
"log-response-header": proxy_config.dig("logging", "response_headers"),
"idle-timeout": seconds_duration(proxy_config.dig("idle", "timeout")),
"idle-wake-timeout": seconds_duration(proxy_config.dig("idle", "wake_timeout")),
"error-pages": error_pages
}.compact
end
Expand Down
19 changes: 18 additions & 1 deletion lib/kamal/configuration/proxy/run.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
require "shellwords"

class Kamal::Configuration::Proxy::Run
MINIMUM_VERSION = "v0.9.2"
DEFAULT_HTTP_PORT = 80
DEFAULT_HTTPS_PORT = 443
DEFAULT_LOG_MAX_SIZE = "10m"
DEFAULT_DOCKER_SOCKET = "/var/run/docker.sock"

attr_reader :config, :run_config
delegate :argumentize, :optionize, to: Kamal::Utils
Expand Down Expand Up @@ -88,12 +91,17 @@ def metrics_port
end

def run_command_options
{ debug: debug? || nil, "metrics-port": metrics_port }.compact
{ debug: debug? || nil, "metrics-port": metrics_port, "docker-socket": docker_socket }.compact
end

def docker_socket
run_config.fetch("docker_socket", DEFAULT_DOCKER_SOCKET) if config.any_service_use_proxy_idle?
end

def docker_options_args
[
*apps_volume_args,
*docker_socket_volume_args,
*publish_args,
*logging_args,
*("--expose=#{metrics_port}" if metrics_port.present?),
Expand Down Expand Up @@ -123,6 +131,15 @@ def apps_volume_args
[ apps_volume.docker_args ]
end

def docker_socket_volume_args
if docker_socket
socket_mount = Shellwords.escape("#{docker_socket}:#{docker_socket}")
socket_path = Shellwords.escape(docker_socket)

[ "--volume=#{socket_mount}", "--group-add", %("$(stat -c %g #{socket_path})") ]
end
end

def app_directory
File.join apps_directory, config.service_and_destination
end
Expand Down
12 changes: 12 additions & 0 deletions lib/kamal/configuration/validator/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ def validate!
end
end

if idle_config = config["idle"]
if idle_config["timeout"].blank?
error "Idle timeout is required"
elsif idle_config["timeout"] <= 0
error "Idle timeout must be greater than zero"
end

if idle_config["wake_timeout"].present? && idle_config["wake_timeout"] <= 0
error "Idle wake timeout must be greater than zero"
end
end

if run_config = config["run"]
if run_config["bind_ips"].present?
ensure_valid_bind_ips(config["bind_ips"])
Expand Down
8 changes: 8 additions & 0 deletions test/commands/app_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@ class CommandsAppTest < ActiveSupport::TestCase
new_command.deploy(target: "172.1.0.2").join(" ")
end

test "deploy with idle" do
@config[:proxy] = { "idle" => { "timeout" => 300, "wake_timeout" => 30 } }

assert_equal \
"docker exec kamal-proxy kamal-proxy deploy app-web --target=\"172.1.0.2:80\" --deploy-timeout=\"30s\" --drain-timeout=\"30s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\" --log-request-header=\"User-Agent\" --idle-timeout=\"300s\" --idle-wake-timeout=\"30s\"",
new_command.deploy(target: "172.1.0.2").join(" ")
end

test "remove" do
assert_equal \
"docker exec kamal-proxy kamal-proxy remove app-web",
Expand Down
40 changes: 40 additions & 0 deletions test/commands/proxy_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,46 @@ class CommandsProxyTest < ActiveSupport::TestCase
new_command.run.join(" ")
end

test "idle mounts and configures the Docker socket" do
@config[:proxy] = { "idle" => { "timeout" => 300 } }

assert_equal \
"docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $PWD/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config --volume=/var/run/docker.sock:/var/run/docker.sock --group-add \"$(stat -c %g /var/run/docker.sock)\" --publish 80:80 --publish 443:443 --log-opt max-size=10m basecamp/kamal-proxy:v0.9.2 kamal-proxy run --docker-socket \"/var/run/docker.sock\"",
new_command.run.join(" ")
end

test "idle mounts, configures and looks up the group for a custom Docker socket" do
@config[:proxy] = {
"idle" => { "timeout" => 300 },
"run" => { "docker_socket" => "/run/user/1000/docker.sock" }
}

command = new_command.run.join(" ")
assert_includes command, "--volume=/run/user/1000/docker.sock:/run/user/1000/docker.sock"
assert_includes command, "--group-add \"$(stat -c %g /run/user/1000/docker.sock)\""
assert_includes command, "kamal-proxy run --docker-socket \"/run/user/1000/docker.sock\""
end

test "Docker socket path is shell escaped" do
@config[:proxy] = {
"idle" => { "timeout" => 300 },
"run" => { "docker_socket" => "/run/docker socket's.sock" }
}

command = new_command.run.join(" ")
assert_includes command, "--volume=/run/docker\\ socket\\'s.sock:/run/docker\\ socket\\'s.sock"
assert_includes command, "--group-add \"$(stat -c %g /run/docker\\ socket\\'s.sock)\""
end

test "Docker socket access is not added when idle is disabled" do
@config[:proxy] = { "run" => { "docker_socket" => "/run/user/1000/docker.sock" } }

command = new_command.run.join(" ")
assert_no_match(/--volume=.*docker\.sock/, command)
assert_no_match(/--group-add/, command)
assert_no_match(/--docker-socket/, command)
end

private
def new_command
Kamal::Commands::Proxy.new(Kamal::Configuration.new(@config, version: "123"), host: "1.1.1.1")
Expand Down
34 changes: 34 additions & 0 deletions test/configuration/proxy/run_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,40 @@ class ConfigurationProxyRunTest < ActiveSupport::TestCase
assert config
end

test "idle uses the default Docker socket" do
deploy = base_deploy.deep_merge(proxy: { "idle" => { "timeout" => 300 } })

assert_equal "/var/run/docker.sock", Kamal::Configuration.new(deploy).proxy.run.docker_socket
end

test "idle uses a custom Docker socket" do
deploy = base_deploy.deep_merge(
proxy: { "idle" => { "timeout" => 300 }, "run" => { "docker_socket" => "/run/user/1000/docker.sock" } }
)

assert_equal "/run/user/1000/docker.sock", Kamal::Configuration.new(deploy).proxy.run.docker_socket
end

test "accessory idle uses the default Docker socket" do
deploy = base_deploy.deep_merge(
accessories: {
"review" => {
"image" => "dhh/review",
"host" => "1.1.1.1",
"proxy" => { "host" => "review.example.com", "idle" => { "timeout" => 300 } }
}
}
)

assert_equal "/var/run/docker.sock", Kamal::Configuration.new(deploy).accessory(:review).proxy.run.docker_socket
end

test "Docker socket lifecycle is disabled without idle" do
deploy = base_deploy.deep_merge(proxy: { "run" => { "docker_socket" => "/run/user/1000/docker.sock" } })

assert_nil Kamal::Configuration.new(deploy).proxy.run.docker_socket
end

private
def base_deploy
{
Expand Down
28 changes: 28 additions & 0 deletions test/configuration/proxy_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,34 @@ class ConfigurationProxyTest < ActiveSupport::TestCase
end
end

test "idle configuration" do
@deploy[:proxy] = { "idle" => { "timeout" => 300, "wake_timeout" => 30 } }

assert_predicate config.proxy, :idle?
assert_equal "300s", config.proxy.deploy_options[:"idle-timeout"]
assert_equal "30s", config.proxy.deploy_options[:"idle-wake-timeout"]
end

test "idle configuration requires integer durations" do
@deploy[:proxy] = { "idle" => { "timeout" => "300" } }

assert_raises(Kamal::ConfigurationError) { config }
end

test "idle configuration requires positive durations" do
@deploy[:proxy] = { "idle" => { "timeout" => 0 } }

error = assert_raises(Kamal::ConfigurationError) { config }
assert_equal "proxy: Idle timeout must be greater than zero", error.message
end

test "idle configuration requires a timeout" do
@deploy[:proxy] = { "idle" => { "wake_timeout" => 30 } }

error = assert_raises(Kamal::ConfigurationError) { config }
assert_equal "proxy: Idle timeout is required", error.message
end

private
def config
Kamal::Configuration.new(@deploy)
Expand Down