diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c48b9a..05e95c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ ## [Unreleased] +## [0.2.0] - 2026-09-15 + +- Replace `cable_port`, `cable_ssl_certificate` and `cable_ssl_certificate_key` with a single `cable_bind` option, defaulting to a unix socket in the shared directory +- Enable systemd socket activation by default, so the listening socket survives a restart of the server +- Install the systemd units at every deploy, before restarting the server +- Stop the deploy on `deploy:starting` when a removed option is still set, instead of moving the server somewhere else in silence + ## [0.1.0] - 2024-07-05 - Initial release diff --git a/README.md b/README.md index 8f12279..7d7c625 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,8 @@ Many options are available to customize the cable server configuration. Here are ```ruby # config/deploy.rb or config/deploy/.rb set :cable_role, :web -set :cable_port, 29292 +set :cable_bind, -> { "unix://#{shared_path.join("tmp", "sockets", "cable.sock")}" } # set :cable_limit_nofile, 65536 # optional, to customize if `Errno::EMFILE: Too many open files` happens -# set :cable_ssl_certificate -# set :cable_ssl_certificate_key set :cable_rackup_file, 'cable/config.ru' set :cable_dir, -> { File.join(release_path, "cable") } set :cable_pidfile, -> { File.join(shared_path, "tmp", "pids", "cable.pid") } @@ -55,14 +53,66 @@ set :cable_env, -> { fetch(:rack_env, fetch(:rails_env, fetch(:stage))) } set :cable_access_log, -> { File.join(shared_path, "log", "cable.access.log") } set :cable_error_log, -> { File.join(shared_path, "log", "cable.error.log") } set :cable_phased_restart, -> { true } +set :cable_enable_socket_service, true set :cable_service_unit_env_files, -> { fetch(:service_unit_env_files, []) } set :cable_service_unit_env_vars, -> { fetch(:service_unit_env_vars, []) } set :cable_service_templates_path, fetch(:service_templates_path, "config/deploy/templates") ``` See Capistrao::Cable::Systemd#set_defaults for more details. -To enable SSL, set the `cable_ssl_certificate` and `cable_ssl_certificate_key` options. -The both are required to enable SSL. +### Where the server listens + +`cable_bind` is the only option about listening. It takes a Puma bind string, or +an array of them: + +```ruby +set :cable_bind, "unix:///home/myapp/public_html/shared/tmp/sockets/cable.sock" +set :cable_bind, "tcp://0.0.0.0:28090" +set :cable_bind, "ssl://0.0.0.0:28090?cert=/path/cert.pem&key=/path/key.pem" +``` + +By default the server listens on a unix socket in the shared directory, which +keeps it unreachable from the outside and saves picking a free port on every +host. The socket directory is created by `cable:install`; the web server in +front then proxies to that socket path instead of a host and port, the way it +proxies to any other unix socket. + +A unix socket path has to be absolute: write `unix:///path/to/cable.sock`, with +three slashes. + +### Socket activation + +With `cable_enable_socket_service` (enabled by default), systemd owns the +listening socket and hands it over to Puma. The socket stays open while the +service restarts, so connections are queued in the backlog instead of being +refused, and the server is started on the first request if it is not running +yet. + +The `ListenStream` of the socket unit is the address of each `cable_bind`: Puma +matches the socket it receives against its own binds, so both are always +written from the same option. + +## Migrating to 0.2.0 + +`cable_port`, `cable_ssl_certificate` and `cable_ssl_certificate_key` are gone, +replaced by `cable_bind`. A deploy that still sets one of them stops on +`deploy:starting`, before anything is uploaded, with a message telling what to +write instead. `cable:install` refuses to run too, for setups that install the +plugin without its hooks. + +Nothing else to do on the Capistrano side: the systemd units are now installed +at every deploy, before the server is restarted, so upgrading the gem and +deploying is enough to move an app to its socket. + +The one manual step is the web server, which has to proxy to the socket instead +of the port. If both can't be changed in the same window, keep the port for +now: + +```ruby +set :cable_bind, "tcp://0.0.0.0:28090" +``` + +and move to the default socket in a later deploy. ## Development diff --git a/lib/capistrano/cable/bind.rb b/lib/capistrano/cable/bind.rb index 02262ec..a518e5e 100644 --- a/lib/capistrano/cable/bind.rb +++ b/lib/capistrano/cable/bind.rb @@ -1,34 +1,38 @@ +# frozen_string_literal: true + module Capistrano module Cable - class Bind < Struct.new(:full_address, :kind, :address) - def unix? - kind == :unix - end + # A Puma bind, as given to `set :cable_bind`: + # + # unix:///home/app/shared/tmp/sockets/cable.sock + # tcp://0.0.0.0:28090 + # ssl://0.0.0.0:28090?cert=/path/cert.pem&key=/path/key.pem + # + # `address` is the part systemd needs for its `ListenStream`: Puma matches + # the socket handed over by systemd against its own binds by address, so + # both have to be written exactly the same way. + class Bind + SCHEMES = %w[tcp ssl unix].freeze - def ssl? - kind == :ssl - end + attr_reader :address - def tcp - kind == :tcp || ssl? - end + def initialize(bind) + @bind = bind.to_s + scheme, rest = @bind.split(":", 2) + @scheme = scheme + @address = rest.to_s.delete_prefix("//").split("?").first.to_s - def local - if unix? - self - else - self.class.new( - localize_address(full_address), - kind, - localize_address(address) - ) - end + raise ArgumentError, "Unsupported cable_bind #{@bind.inspect}, expected #{SCHEMES.join("://, ")}://" unless SCHEMES.include?(@scheme) + raise ArgumentError, "Empty address in cable_bind #{@bind.inspect}" if @address.empty? + raise ArgumentError, "cable_bind #{@bind.inspect} needs an absolute socket path" if unix? && !@address.start_with?("/") end - private + def unix? + @scheme == "unix" + end - def localize_address(address) - address.gsub(/0\.0\.0\.0(.+)/, "127.0.0.1\\1") + def to_s + @bind end end end diff --git a/lib/capistrano/cable/systemd.rb b/lib/capistrano/cable/systemd.rb index e4fbd1f..138e6ac 100644 --- a/lib/capistrano/cable/systemd.rb +++ b/lib/capistrano/cable/systemd.rb @@ -1,10 +1,23 @@ require "capistrano/plugin" +require "erb" +require "stringio" require_relative "bind" module Capistrano module Cable class Systemd < Capistrano::Plugin + # Options dropped in favour of :cable_bind. They are still looked up so + # that a stale setting stops the deploy before it does anything, instead + # of silently moving the server to another address. + REMOVED_OPTIONS = { + cable_port: 'set :cable_bind, "tcp://0.0.0.0:"', + cable_ssl_certificate: 'set :cable_bind, "ssl://0.0.0.0:?cert=&key="', + cable_ssl_certificate_key: 'set :cable_bind, "ssl://0.0.0.0:?cert=&key="' + }.freeze + def register_hooks + before "deploy:starting", "cable:check" + after "deploy:finished", "cable:install" after "deploy:finished", "cable:smart_restart" end @@ -14,7 +27,7 @@ def define_tasks def set_defaults set_if_empty :cable_role, :web - set_if_empty :cable_port, 29292 + set_if_empty :cable_bind, -> { "unix://#{shared_path.join("tmp", "sockets", "cable.sock")}" } set_if_empty :cable_rackup_file, "cable/config.ru" set_if_empty :cable_dir, -> { File.join(release_path, "cable") } set_if_empty :cable_pidfile, -> { File.join(shared_path, "tmp", "pids", "cable.pid") } @@ -25,9 +38,8 @@ def set_defaults set_if_empty :cable_systemctl_bin, -> { fetch(:systemctl_bin, "/bin/systemctl") } set_if_empty :cable_service_unit_name, -> { "#{fetch(:application)}_cable_#{fetch(:stage)}" } - set_if_empty :cable_enable_socket_service, false + set_if_empty :cable_enable_socket_service, true set_if_empty :cable_socket_unit_name, -> { "#{fetch(:application)}_cable_#{fetch(:stage)}.socket" } - # set_if_empty :cable_bind, -> { "unix:/tmp/#{fetch(:app_domain)}.sock" } set_if_empty :cable_service_unit_env_files, -> { fetch(:service_unit_env_files, []) } set_if_empty :cable_service_unit_env_vars, -> { fetch(:service_unit_env_vars, []) } @@ -47,6 +59,13 @@ def set_defaults append :bundle_bins, "puma", "pumactl" end + def check_removed_options! + messages = REMOVED_OPTIONS.select { |option, _| fetch(option) }.map do |option, replacement| + ":#{option} is not supported anymore, use :cable_bind instead (#{replacement})" + end + raise ArgumentError, messages.join("\n") if messages.any? + end + def expanded_bundle_command backend.capture(:echo, SSHKit.config.command_map[:bundle]).strip end @@ -103,12 +122,6 @@ def cable_user(role) role.user end - def cable_bind - Array(fetch(:cable_bind)).collect do |bind| - "bind '#{bind}'" - end.join("\n") - end - def service_unit_type ## Jruby don't support notify return "simple" if RUBY_ENGINE == "jruby" @@ -120,11 +133,7 @@ def service_unit_type def puma_options options = [] options << "--no-config" - options << if fetch(:cable_ssl_certificate) && fetch(:cable_ssl_certificate_key) - "--bind 'ssl://0.0.0.0:#{fetch(:cable_port)}?key=#{fetch(:cable_ssl_certificate_key)}&cert=#{fetch(:cable_ssl_certificate)}'" - else - "--port #{fetch(:cable_port)}" - end + cable_binds.each { |bind| options << "--bind '#{bind}'" } options << "--environment #{fetch(:cable_env)}" options << "--pidfile #{fetch(:cable_pidfile)}" if fetch(:cable_pidfile) options << "--threads #{fetch(:cable_threads)}" if fetch(:cable_threads) @@ -156,10 +165,11 @@ def upload_template_cable(from, to, role) end def cable_binds - Array(fetch(:cable_bind)).map do |m| - etype, address = /(tcp|unix|ssl):\/{1,2}(.+)/.match(m).captures - Bind.new(m, etype.to_sym, address) - end + Array(fetch(:cable_bind)).map { |bind| Bind.new(bind) } + end + + def cable_socket_dirs + cable_binds.select(&:unix?).map { |bind| File.dirname(bind.address) }.uniq end end end diff --git a/lib/capistrano/cable/version.rb b/lib/capistrano/cable/version.rb index 86107e3..23b6bde 100644 --- a/lib/capistrano/cable/version.rb +++ b/lib/capistrano/cable/version.rb @@ -2,6 +2,6 @@ module Capistrano module Cable - VERSION = "0.1.3" + VERSION = "0.2.0" end end diff --git a/lib/capistrano/tasks/systemd.rake b/lib/capistrano/tasks/systemd.rake index 3b52c44..1dbe03e 100644 --- a/lib/capistrano/tasks/systemd.rake +++ b/lib/capistrano/tasks/systemd.rake @@ -3,8 +3,14 @@ git_plugin = self namespace :cable do + desc "Check Cable configuration" + task :check do + git_plugin.check_removed_options! + end + desc "Install Cable systemd service" task :install do + git_plugin.check_removed_options! on roles(fetch(:cable_role)) do |role| upload_compiled_template = lambda do |template_name, unit_filename| git_plugin.upload_template_cable template_name, "#{fetch(:tmp_dir)}/#{unit_filename}", role @@ -17,6 +23,8 @@ namespace :cable do end end + git_plugin.cable_socket_dirs.each { |dir| execute :mkdir, "-p", dir } + upload_compiled_template.call("cable.service", "#{fetch(:cable_service_unit_name)}.service") if fetch(:cable_enable_socket_service) diff --git a/lib/capistrano/templates/cable.service.erb b/lib/capistrano/templates/cable.service.erb index c539a55..c4b7565 100644 --- a/lib/capistrano/templates/cable.service.erb +++ b/lib/capistrano/templates/cable.service.erb @@ -12,7 +12,7 @@ [Unit] Description=Cable HTTP Server for <%= "#{fetch(:application)} (#{fetch(:stage)})" %> <%= "Requires=#{fetch(:cable_socket_unit_name)}" if fetch(:cable_enable_socket_service) %> -After=syslog.target network.target +After=syslog.target network.target<%= " #{fetch(:cable_socket_unit_name)}" if fetch(:cable_enable_socket_service) %> [Service] Type=<%= service_unit_type %> diff --git a/lib/capistrano/templates/cable.socket.erb b/lib/capistrano/templates/cable.socket.erb index a4bde87..d14db63 100644 --- a/lib/capistrano/templates/cable.socket.erb +++ b/lib/capistrano/templates/cable.socket.erb @@ -3,17 +3,19 @@ Description=Cable Puma HTTP Server Accept Sockets for <%= "#{fetch(:application) [Socket] <% cable_binds.each do |bind| -%> -<%= "ListenStream=#{bind.local.address}" %> +ListenStream=<%= bind.address %> <% end -%> # Don't let systemd accept the request, wait for Cable to do that. # Systemd will start the cable service upon first request if it wasn't started. # -# You might also want to set your Nginx upstream to have a fail_timeout large enough to accomodate your app's -# startup time. +# Systemd keeps the socket open across restarts of the service: connections are +# queued in the backlog instead of being refused while Cable boots. +# +# You might also want to give the web server in front a connection timeout large +# enough to accommodate your app's startup time. Accept=no -<%= "NoDelay=true" if fetch(:cable_systemctl_user) == :system %> -ReusePort=true +<%= "NoDelay=true" unless cable_binds.all?(&:unix?) %> Backlog=1024 SyslogIdentifier=<%= fetch(:cable_socket_unit_name) %> diff --git a/test/capistrano/test_bind.rb b/test/capistrano/test_bind.rb new file mode 100644 index 0000000..5ac2c8c --- /dev/null +++ b/test/capistrano/test_bind.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "test_helper" + +class Capistrano::Cable::TestBind < Minitest::Test + def test_unix_bind_keeps_its_absolute_path + ["unix:///app/shared/tmp/sockets/cable.sock", "unix:/app/shared/tmp/sockets/cable.sock"].each do |bind| + assert_equal "/app/shared/tmp/sockets/cable.sock", Capistrano::Cable::Bind.new(bind).address + assert Capistrano::Cable::Bind.new(bind).unix? + end + end + + def test_tcp_bind_address_is_host_and_port + bind = Capistrano::Cable::Bind.new("tcp://0.0.0.0:28090") + + assert_equal "0.0.0.0:28090", bind.address + refute bind.unix? + end + + def test_ssl_bind_address_drops_the_certificate_options + bind = Capistrano::Cable::Bind.new("ssl://0.0.0.0:28090?cert=/path/cert.pem&key=/path/key.pem") + + assert_equal "0.0.0.0:28090", bind.address + refute bind.unix? + end + + def test_bind_keeps_the_whole_string_for_puma + bind = "ssl://0.0.0.0:28090?cert=/path/cert.pem&key=/path/key.pem" + + assert_equal bind, Capistrano::Cable::Bind.new(bind).to_s + end + + def test_unsupported_scheme_is_rejected + error = assert_raises(ArgumentError) { Capistrano::Cable::Bind.new("http://0.0.0.0:28090") } + + assert_includes error.message, "Unsupported cable_bind" + end + + def test_address_less_bind_is_rejected + assert_raises(ArgumentError) { Capistrano::Cable::Bind.new("unix://") } + end + + def test_relative_socket_path_is_rejected + error = assert_raises(ArgumentError) { Capistrano::Cable::Bind.new("unix://cable.sock") } + + assert_includes error.message, "absolute socket path" + end +end diff --git a/test/capistrano/test_systemd.rb b/test/capistrano/test_systemd.rb new file mode 100644 index 0000000..64f2322 --- /dev/null +++ b/test/capistrano/test_systemd.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "test_helper" + +class Capistrano::Cable::TestSystemd < Minitest::Test + Role = Struct.new(:hostname, :user, :properties) + + # The service template shells out to read the bundle command, which needs a + # connection we don't have in tests. + class Plugin < Capistrano::Cable::Systemd + def expanded_bundle_command + "/usr/bin/bundle" + end + end + + def setup + Capistrano::Configuration.reset! + set(:application, "myapp") + set(:stage, "production") + set(:deploy_to, "/home/myapp/public_html") + set(:default_env, {}) + end + + def test_default_bind_is_a_unix_socket_in_the_shared_directory + assert_equal ["unix:///home/myapp/public_html/shared/tmp/sockets/cable.sock"], + plugin.cable_binds.map(&:to_s) + end + + def test_puma_listens_on_every_configured_bind + options = plugin(cable_bind: ["unix:///tmp/cable.sock", "tcp://0.0.0.0:28090"]).puma_options + + assert_includes options, "--bind 'unix:///tmp/cable.sock'" + assert_includes options, "--bind 'tcp://0.0.0.0:28090'" + refute_includes options, "--port" + end + + def test_socket_directories_are_only_needed_by_unix_binds + assert_equal ["/var/run/myapp"], + plugin(cable_bind: ["unix:///var/run/myapp/cable.sock", "tcp://0.0.0.0:28090"]).cable_socket_dirs + end + + def test_removed_options_stop_the_install + error = assert_raises(ArgumentError) { plugin(cable_port: 28090).check_removed_options! } + + assert_includes error.message, ":cable_port is not supported anymore" + assert_includes error.message, 'set :cable_bind, "tcp://0.0.0.0:"' + end + + def test_supported_options_let_the_install_run + assert_nil plugin.check_removed_options! + end + + def test_configuration_is_checked_before_the_deploy_starts + ran = hook_deploy_tasks + Rake::Task["deploy:starting"].invoke + + assert_equal ["check"], ran + end + + def test_units_are_installed_at_every_deploy_before_the_server_is_restarted + ran = hook_deploy_tasks + Rake::Task["deploy:finished"].invoke + + assert_equal ["install", "smart_restart"], ran + end + + def test_socket_unit_listens_on_every_bind + unit = render("cable.socket", cable_bind: ["unix:///tmp/cable.sock", "unix:///tmp/other.sock"]) + + assert_includes unit, "ListenStream=/tmp/cable.sock\n" + assert_includes unit, "ListenStream=/tmp/other.sock\n" + refute_includes unit, "NoDelay" + end + + def test_socket_unit_disables_nagle_algorithm_on_tcp_binds + assert_includes render("cable.socket", cable_bind: "tcp://0.0.0.0:28090"), "NoDelay=true" + end + + def test_service_unit_waits_for_its_socket_unit + unit = render("cable.service") + + assert_includes unit, "Requires=myapp_cable_production.socket\n" + assert_includes unit, "After=syslog.target network.target myapp_cable_production.socket\n" + end + + def test_service_unit_ignores_the_socket_unit_when_it_is_disabled + unit = render("cable.service", cable_enable_socket_service: false) + + refute_includes unit, "myapp_cable_production.socket" + assert_includes unit, "After=syslog.target network.target\n" + end + + def test_service_unit_starts_puma_on_the_configured_bind + unit = render("cable.service", cable_bind: "unix:///tmp/cable.sock") + + assert_includes unit, "ExecStart=/usr/bin/bundle exec puma --no-config --bind 'unix:///tmp/cable.sock'" + end + + private + + def set(key, value) + Capistrano::Configuration.env.set(key, value) + end + + def plugin(options = {}) + options.each { |key, value| set(key, value) } + Plugin.new.tap(&:set_defaults) + end + + # Replaces the deploy and cable tasks with stubs recording what they run, and + # registers the plugin hooks on them. + def hook_deploy_tasks + ran = [] + Rake::Task.clear + ["deploy:starting", "deploy:finished"].each { |name| Rake::Task.define_task(name) } + ["cable:check", "cable:install", "cable:smart_restart"].each do |name| + Rake::Task.define_task(name) { ran << name.delete_prefix("cable:") } + end + plugin.register_hooks + ran + end + + def render(template, options = {}) + plugin(options) + .compiled_template_cable(template, Role.new("example.com", "deploy", {})) + .read + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index c631382..5b09bdd 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true $LOAD_PATH.unshift File.expand_path("../lib", __dir__) +require "capistrano/cable" require "capistrano/cable/version" require "minitest/autorun"