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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
60 changes: 55 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,25 +44,75 @@ Many options are available to customize the cable server configuration. Here are
```ruby
# config/deploy.rb or config/deploy/<stage>.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") }
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

Expand Down
50 changes: 27 additions & 23 deletions lib/capistrano/cable/bind.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
46 changes: 28 additions & 18 deletions lib/capistrano/cable/systemd.rb
Original file line number Diff line number Diff line change
@@ -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:<port>"',
cable_ssl_certificate: 'set :cable_bind, "ssl://0.0.0.0:<port>?cert=<cert>&key=<key>"',
cable_ssl_certificate_key: 'set :cable_bind, "ssl://0.0.0.0:<port>?cert=<cert>&key=<key>"'
}.freeze

def register_hooks
before "deploy:starting", "cable:check"
after "deploy:finished", "cable:install"
after "deploy:finished", "cable:smart_restart"
end

Expand All @@ -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") }
Expand All @@ -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, []) }
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/capistrano/cable/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

module Capistrano
module Cable
VERSION = "0.1.3"
VERSION = "0.2.0"
end
end
8 changes: 8 additions & 0 deletions lib/capistrano/tasks/systemd.rake
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion lib/capistrano/templates/cable.service.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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 %>
Expand Down
12 changes: 7 additions & 5 deletions lib/capistrano/templates/cable.socket.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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) %>
Expand Down
48 changes: 48 additions & 0 deletions test/capistrano/test_bind.rb
Original file line number Diff line number Diff line change
@@ -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
Loading