diff --git a/Library/Homebrew/service.rb b/Library/Homebrew/service.rb index 8a40f3d919..6672959abf 100644 --- a/Library/Homebrew/service.rb +++ b/Library/Homebrew/service.rb @@ -18,6 +18,8 @@ module Homebrew 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 @@ -100,15 +102,41 @@ module Homebrew 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 + 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 ://:" 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 @@ -117,7 +145,7 @@ module Homebrew 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)) } @@ -310,7 +338,6 @@ module Homebrew 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? @@ -323,6 +350,28 @@ module Homebrew 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 @@ -350,7 +399,8 @@ module Homebrew 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? diff --git a/Library/Homebrew/test/service_spec.rb b/Library/Homebrew/test/service_spec.rb index 7a55c9ac3b..f54d8d8be6 100644 --- a/Library/Homebrew/test/service_spec.rb +++ b/Library/Homebrew/test/service_spec.rb @@ -45,6 +45,19 @@ describe Homebrew::Service do 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 @@ -58,6 +71,41 @@ describe Homebrew::Service do 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 ://:" + 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 ://:" + 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 ://:" + end + end + describe "#manual_command" do it "returns valid manual_command" do f.class.service do @@ -159,6 +207,47 @@ describe Homebrew::Service do 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 + + + + + \tLabel + \thomebrew.mxcl.formula_name + \tProgramArguments + \t + \t\t#{HOMEBREW_PREFIX}/opt/formula_name/bin/beanstalkd + \t\ttest + \t + \tRunAtLoad + \t + \tSockets + \t + \t\tListeners + \t\t + \t\t\tSockFamily + \t\t\tIPv4v6 + \t\t\tSockNodeName + \t\t\t127.0.0.1 + \t\t\tSockProtocol + \t\t\tTCP + \t\t\tSockServiceName + \t\t\t80 + \t\t + \t + + + EOS + expect(plist).to eq(plist_expect) + end + it "returns valid partial plist" do f.class.service do run opt_bin/"beanstalkd" @@ -247,6 +336,99 @@ describe Homebrew::Service do 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 + + + + + \tKeepAlive + \t + \t\tSuccessfulExit + \t\t + \t + \tLabel + \thomebrew.mxcl.formula_name + \tProgramArguments + \t + \t\t#{HOMEBREW_PREFIX}/opt/formula_name/bin/beanstalkd + \t + \tRunAtLoad + \t + + + 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 + + + + + \tKeepAlive + \t + \t\tCrashed + \t\t + \t + \tLabel + \thomebrew.mxcl.formula_name + \tProgramArguments + \t + \t\t#{HOMEBREW_PREFIX}/opt/formula_name/bin/beanstalkd + \t + \tRunAtLoad + \t + + + 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 + + + + + \tKeepAlive + \t + \t\tPathState + \t\t#{HOMEBREW_PREFIX}/opt/formula_name/share/formula_name/test-path + \t + \tLabel + \thomebrew.mxcl.formula_name + \tProgramArguments + \t + \t\t#{HOMEBREW_PREFIX}/opt/formula_name/bin/beanstalkd + \t + \tRunAtLoad + \t + + + EOS + expect(plist).to eq(plist_expect) + end end describe "#to_systemd_unit" do @@ -426,6 +608,15 @@ describe Homebrew::Service do 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"] diff --git a/docs/Formula-Cookbook.md b/docs/Formula-Cookbook.md index 5ca891fdd7..08395d85c2 100644 --- a/docs/Formula-Cookbook.md +++ b/docs/Formula-Cookbook.md @@ -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 @@ -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`: `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.