diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index 608e9f8ee..20902547f 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -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 diff --git a/lib/kamal/configuration/docs/proxy.yml b/lib/kamal/configuration/docs/proxy.yml index 87e428e3c..ef85343fb 100644 --- a/lib/kamal/configuration/docs/proxy.yml +++ b/lib/kamal/configuration/docs/proxy.yml @@ -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. @@ -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 diff --git a/lib/kamal/configuration/proxy.rb b/lib/kamal/configuration/proxy.rb index 3f2d83fad..97fff4b00 100644 --- a/lib/kamal/configuration/proxy.rb +++ b/lib/kamal/configuration/proxy.rb @@ -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 @@ -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 diff --git a/lib/kamal/configuration/proxy/run.rb b/lib/kamal/configuration/proxy/run.rb index 2a1d8521d..314f26574 100644 --- a/lib/kamal/configuration/proxy/run.rb +++ b/lib/kamal/configuration/proxy/run.rb @@ -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 @@ -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?), @@ -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 diff --git a/lib/kamal/configuration/validator/proxy.rb b/lib/kamal/configuration/validator/proxy.rb index 5dc3fd46b..3e025cb87 100644 --- a/lib/kamal/configuration/validator/proxy.rb +++ b/lib/kamal/configuration/validator/proxy.rb @@ -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"]) diff --git a/test/commands/app_test.rb b/test/commands/app_test.rb index dac14ef50..99a5b3221 100644 --- a/test/commands/app_test.rb +++ b/test/commands/app_test.rb @@ -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", diff --git a/test/commands/proxy_test.rb b/test/commands/proxy_test.rb index 77e9a53ac..3174afdd5 100644 --- a/test/commands/proxy_test.rb +++ b/test/commands/proxy_test.rb @@ -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") diff --git a/test/configuration/proxy/run_test.rb b/test/configuration/proxy/run_test.rb index d53836b2f..45c47b361 100644 --- a/test/configuration/proxy/run_test.rb +++ b/test/configuration/proxy/run_test.rb @@ -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 { diff --git a/test/configuration/proxy_test.rb b/test/configuration/proxy_test.rb index e0b328f3b..43814285e 100644 --- a/test/configuration/proxy_test.rb +++ b/test/configuration/proxy_test.rb @@ -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)