Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

service: add sockets and keepalive variants #12790

Merged
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
60 changes: 55 additions & 5 deletions Library/Homebrew/service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class Service
PROCESS_TYPE_INTERACTIVE = :interactive
PROCESS_TYPE_ADAPTIVE = :adaptive

KEEP_ALIVE_KEYS = [:always, :successful_exit, :crashed, :path].freeze

# sig { params(formula: Formula).void }
def initialize(formula, &block)
@formula = formula
Expand Down Expand Up @@ -100,15 +102,41 @@ def error_log_path(path = nil)
end
end

sig { params(value: T.nilable(T::Boolean)).returns(T.nilable(T::Boolean)) }
sig {
params(value: T.nilable(T.any(T::Boolean, T::Hash[Symbol, T.untyped])))
.returns(T.nilable(T::Hash[Symbol, T.untyped]))
}
def keep_alive(value = nil)
case T.unsafe(value)
when nil
@keep_alive
when true, false
@keep_alive = { always: value }
when Hash
SMillerDev marked this conversation as resolved.
Show resolved Hide resolved
hash = T.cast(value, Hash)
unless (hash.keys - KEEP_ALIVE_KEYS).empty?
raise TypeError, "Service#keep_alive allows only #{KEEP_ALIVE_KEYS}"
end

@keep_alive = value
else
raise TypeError, "Service#keep_alive expects a Boolean"
raise TypeError, "Service#keep_alive expects a Boolean or Hash"
end
end

sig { params(value: T.nilable(String)).returns(T.nilable(T::Hash[Symbol, String])) }
def sockets(value = nil)
case T.unsafe(value)
when nil
@sockets
when String
match = T.must(value).match(%r{([a-z]+)://([a-z0-9.]+):([0-9]+)}i)
raise TypeError, "Service#sockets a formatted socket definition as <type>://<host>:<port>" if match.blank?

type, host, port = match.captures
@sockets = { host: host, port: port, type: type }
else
raise TypeError, "Service#sockets expects a String"
end
end

Expand All @@ -117,7 +145,7 @@ def keep_alive(value = nil)
sig { returns(T::Boolean) }
def keep_alive?
instance_eval(&@service_block)
@keep_alive == true
@keep_alive.present? && @keep_alive[:always] != false
end

sig { params(value: T.nilable(T::Boolean)).returns(T.nilable(T::Boolean)) }
Expand Down Expand Up @@ -310,7 +338,6 @@ def to_plist
RunAtLoad: @run_type == RUN_TYPE_IMMEDIATE,
}

base[:KeepAlive] = @keep_alive if @keep_alive == true
base[:LaunchOnlyOnce] = @launch_only_once if @launch_only_once == true
base[:LegacyTimers] = @macos_legacy_timers if @macos_legacy_timers == true
base[:TimeOut] = @restart_delay if @restart_delay.present?
Expand All @@ -323,6 +350,28 @@ def to_plist
base[:StandardErrorPath] = @error_log_path if @error_log_path.present?
base[:EnvironmentVariables] = @environment_variables unless @environment_variables.empty?

if keep_alive?
if (always = @keep_alive[:always].presence)
base[:KeepAlive] = always
elsif @keep_alive.key?(:successful_exit)
base[:KeepAlive] = { SuccessfulExit: @keep_alive[:successful_exit] }
elsif @keep_alive.key?(:crashed)
base[:KeepAlive] = { Crashed: @keep_alive[:crashed] }
elsif @keep_alive.key?(:path) && @keep_alive[:path].present?
base[:KeepAlive] = { PathState: @keep_alive[:path].to_s }
end
end

if @sockets.present?
base[:Sockets] = {}
base[:Sockets][:Listeners] = {
SockNodeName: @sockets[:host],
SockServiceName: @sockets[:port],
SockProtocol: @sockets[:type].upcase,
SockFamily: "IPv4v6",
}
end

if @cron.present? && @run_type == RUN_TYPE_CRON
base[:StartCalendarInterval] = @cron.reject { |_, value| value == "*" }
end
Expand Down Expand Up @@ -350,7 +399,8 @@ def to_systemd_unit
options = []
options << "Type=#{@launch_only_once == true ? "oneshot" : "simple"}"
options << "ExecStart=#{cmd}"
options << "Restart=always" if @keep_alive == true

options << "Restart=always" if @keep_alive.present? && @keep_alive[:always].present?
options << "RestartSec=#{restart_delay}" if @restart_delay.present?
options << "WorkingDirectory=#{@working_dir}" if @working_dir.present?
options << "RootDirectory=#{@root_dir}" if @root_dir.present?
Expand Down
191 changes: 191 additions & 0 deletions Library/Homebrew/test/service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@
end
end

describe "#keep_alive" do
it "throws for unexpected keys" do
f.class.service do
run opt_bin/"beanstalkd"
keep_alive test: "key"
end

expect {
f.service.manual_command
}.to raise_error TypeError, "Service#keep_alive allows only [:always, :successful_exit, :crashed, :path]"
end
end

describe "#run_type" do
it "throws for unexpected type" do
f.class.service do
Expand All @@ -58,6 +71,41 @@
end
end

describe "#sockets" do
it "throws for missing type" do
f.class.service do
run opt_bin/"beanstalkd"
sockets "127.0.0.1:80"
end

expect {
f.service.manual_command
}.to raise_error TypeError, "Service#sockets a formatted socket definition as <type>://<host>:<port>"
end

it "throws for missing host" do
f.class.service do
run opt_bin/"beanstalkd"
sockets "tcp://:80"
end

expect {
f.service.manual_command
}.to raise_error TypeError, "Service#sockets a formatted socket definition as <type>://<host>:<port>"
end

it "throws for missing port" do
f.class.service do
run opt_bin/"beanstalkd"
sockets "tcp://127.0.0.1"
end

expect {
f.service.manual_command
}.to raise_error TypeError, "Service#sockets a formatted socket definition as <type>://<host>:<port>"
end
end

describe "#manual_command" do
it "returns valid manual_command" do
f.class.service do
Expand Down Expand Up @@ -159,6 +207,47 @@
expect(plist).to eq(plist_expect)
end

it "returns valid plist with socket" do
f.class.service do
run [opt_bin/"beanstalkd", "test"]
sockets "tcp://127.0.0.1:80"
end

plist = f.service.to_plist
plist_expect = <<~EOS
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
\t<key>Label</key>
\t<string>homebrew.mxcl.formula_name</string>
\t<key>ProgramArguments</key>
\t<array>
\t\t<string>#{HOMEBREW_PREFIX}/opt/formula_name/bin/beanstalkd</string>
\t\t<string>test</string>
\t</array>
\t<key>RunAtLoad</key>
\t<true/>
\t<key>Sockets</key>
\t<dict>
\t\t<key>Listeners</key>
\t\t<dict>
\t\t\t<key>SockFamily</key>
\t\t\t<string>IPv4v6</string>
\t\t\t<key>SockNodeName</key>
\t\t\t<string>127.0.0.1</string>
\t\t\t<key>SockProtocol</key>
\t\t\t<string>TCP</string>
\t\t\t<key>SockServiceName</key>
\t\t\t<string>80</string>
\t\t</dict>
\t</dict>
</dict>
</plist>
EOS
expect(plist).to eq(plist_expect)
end

it "returns valid partial plist" do
f.class.service do
run opt_bin/"beanstalkd"
Expand Down Expand Up @@ -247,6 +336,99 @@
EOS
expect(plist).to eq(plist_expect)
end

it "returns valid keepalive-exit plist" do
f.class.service do
run opt_bin/"beanstalkd"
keep_alive successful_exit: false
end

plist = f.service.to_plist
plist_expect = <<~EOS
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
\t<key>KeepAlive</key>
\t<dict>
\t\t<key>SuccessfulExit</key>
\t\t<false/>
\t</dict>
\t<key>Label</key>
\t<string>homebrew.mxcl.formula_name</string>
\t<key>ProgramArguments</key>
\t<array>
\t\t<string>#{HOMEBREW_PREFIX}/opt/formula_name/bin/beanstalkd</string>
\t</array>
\t<key>RunAtLoad</key>
\t<true/>
</dict>
</plist>
EOS
expect(plist).to eq(plist_expect)
end

it "returns valid keepalive-crashed plist" do
f.class.service do
run opt_bin/"beanstalkd"
keep_alive crashed: true
end

plist = f.service.to_plist
plist_expect = <<~EOS
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
\t<key>KeepAlive</key>
\t<dict>
\t\t<key>Crashed</key>
\t\t<true/>
\t</dict>
\t<key>Label</key>
\t<string>homebrew.mxcl.formula_name</string>
\t<key>ProgramArguments</key>
\t<array>
\t\t<string>#{HOMEBREW_PREFIX}/opt/formula_name/bin/beanstalkd</string>
\t</array>
\t<key>RunAtLoad</key>
\t<true/>
</dict>
</plist>
EOS
expect(plist).to eq(plist_expect)
end

it "returns valid keepalive-path plist" do
f.class.service do
run opt_bin/"beanstalkd"
keep_alive path: opt_pkgshare/"test-path"
end

plist = f.service.to_plist
plist_expect = <<~EOS
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
\t<key>KeepAlive</key>
\t<dict>
\t\t<key>PathState</key>
\t\t<string>#{HOMEBREW_PREFIX}/opt/formula_name/share/formula_name/test-path</string>
\t</dict>
\t<key>Label</key>
\t<string>homebrew.mxcl.formula_name</string>
\t<key>ProgramArguments</key>
\t<array>
\t\t<string>#{HOMEBREW_PREFIX}/opt/formula_name/bin/beanstalkd</string>
\t</array>
\t<key>RunAtLoad</key>
\t<true/>
</dict>
</plist>
EOS
expect(plist).to eq(plist_expect)
end
end

describe "#to_systemd_unit" do
Expand Down Expand Up @@ -426,6 +608,15 @@
end

describe "#keep_alive?" do
it "returns true when keep_alive set to hash" do
f.class.service do
run [opt_bin/"beanstalkd", "test"]
keep_alive crashed: true
end

expect(f.service.keep_alive?).to be(true)
end

it "returns true when keep_alive set to true" do
f.class.service do
run [opt_bin/"beanstalkd", "test"]
Expand Down
50 changes: 50 additions & 0 deletions docs/Formula-Cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,7 @@ The only required field in a `service` block is the `run` field to indicate what
| `restart_delay` | - | yes | yes | The delay before restarting a process |
| `process_type` | - | yes | no-op | The type of process to manage, `:background`, `:standard`, `:interactive` or `:adaptive` |
| `macos_legacy_timers` | - | yes | no-op | Timers created by launchd jobs are coalesced unless this is set |
| `sockets` | - | yes | no-op | A socket that is created as an accesspoint to the service |

For services that start and keep running alive you can use the default `run_type :` like so:
```ruby
Expand Down Expand Up @@ -836,6 +837,55 @@ This method will set the path to `#{HOMEBREW_PREFIX}/bin:#{HOMEBREW_PREFIX}/sbin
end
```

#### KeepAlive options
The standard options, keep alive regardless of any status or circomstances
```rb
service do
run [opt_bin/"beanstalkd", "test"]
keep_alive true # or false
end
```

Same as above in hash form
```rb
service do
run [opt_bin/"beanstalkd", "test"]
keep_alive { always: true }
end
```

Keep alive until the job exits with a non-zero return code
```rb
service do
run [opt_bin/"beanstalkd", "test"]
keep_alive { succesful_exit: true }
end
```

Keep alive only if the job crashed
```rb
service do
run [opt_bin/"beanstalkd", "test"]
keep_alive { crashed: true }
end
```

Keep alive as long as a file exists
```rb
service do
run [opt_bin/"beanstalkd", "test"]
keep_alive { path: "/some/path" }
end
```

#### Socket format
The sockets method accepts a formatted socket definition as `<type>://<host>:<port>`.
- `type`: `udp` or `tcp`
- `host`: The host to run the socket on. For example `0.0.0.0`
- `port`: The port the socket should listen on.

Please note that sockets will be accessible on IPv4 and IPv6 addresses by default.

### Using environment variables

Homebrew has multiple levels of environment variable filtering which affects variables available to formulae.
Expand Down