diff --git a/Library/Homebrew/service.rb b/Library/Homebrew/service.rb index 130e2f1e7e..09e2fb6b59 100644 --- a/Library/Homebrew/service.rb +++ b/Library/Homebrew/service.rb @@ -1,6 +1,7 @@ # typed: true # frozen_string_literal: true +require "ipaddr" require "extend/on_system" module Homebrew @@ -187,17 +188,33 @@ module Homebrew end end - sig { params(value: T.nilable(String)).returns(T.nilable(T::Hash[Symbol, String])) } + SOCKET_STRING_REGEX = %r{^([a-z]+)://(.+):([0-9]+)$}i.freeze + + sig { + params(value: T.nilable(T.any(String, T::Hash[Symbol, String]))) + .returns(T.nilable(T::Hash[Symbol, T::Hash[Symbol, String]])) + } def sockets(value = nil) - case value - when nil - @sockets + return @sockets if value.nil? + + @sockets = case value when String - match = T.must(value).match(%r{([a-z]+)://([a-z0-9.]+):([0-9]+)}i) + { listeners: value } + when Hash + value + end.transform_values do |socket_string| + match = socket_string.match(SOCKET_STRING_REGEX) raise TypeError, "Service#sockets a formatted socket definition as ://:" if match.blank? type, host, port = match.captures - @sockets = { host: host, port: port, type: type } + + begin + IPAddr.new(host) + rescue IPAddr::InvalidAddressError + raise TypeError, "Service#sockets expects a valid ipv4 or ipv6 host address" + end + + { host: host, port: port, type: type } end end @@ -410,12 +427,13 @@ module Homebrew if @sockets.present? base[:Sockets] = {} - base[:Sockets][:Listeners] = { - SockNodeName: @sockets[:host], - SockServiceName: @sockets[:port], - SockProtocol: @sockets[:type].upcase, - SockFamily: "IPv4v6", - } + @sockets.each do |name, info| + base[:Sockets][name] = { + SockNodeName: info[:host], + SockServiceName: info[:port], + SockProtocol: info[:type].upcase, + } + end end if @cron.present? && @run_type == RUN_TYPE_CRON @@ -511,7 +529,20 @@ module Homebrew .join(" ") end - sockets_string = "#{@sockets[:type]}://#{@sockets[:host]}:#{@sockets[:port]}" if @sockets.present? + sockets_var = if @sockets.present? + @sockets.transform_values { |info| "#{info[:type]}://#{info[:host]}:#{info[:port]}" } + .then do |sockets_hash| + # TODO: Remove this code when all users are running on versions of Homebrew + # that can process sockets hashes (this commit or later). + if sockets_hash.size == 1 && sockets_hash.key?(:listeners) + # When original #sockets argument was a string: `sockets "tcp://127.0.0.1:80"` + sockets_hash.fetch(:listeners) + else + # When original #sockets argument was a hash: `sockets http: "tcp://0.0.0.0:80"` + sockets_hash + end + end + end { name: name_params.presence, @@ -531,7 +562,7 @@ module Homebrew restart_delay: @restart_delay, process_type: @process_type, macos_legacy_timers: @macos_legacy_timers, - sockets: sockets_string, + sockets: sockets_var, }.compact end @@ -565,8 +596,6 @@ module Homebrew raise ArgumentError, "Unexpected run command: #{api_hash["run"]}" end - hash[:keep_alive] = api_hash["keep_alive"].transform_keys(&:to_sym) if api_hash.key?("keep_alive") - if api_hash.key?("environment_variables") hash[:environment_variables] = api_hash["environment_variables"].to_h do |key, value| [key.to_sym, replace_placeholders(value)] @@ -585,12 +614,22 @@ module Homebrew hash[key.to_sym] = replace_placeholders(value) end - %w[interval cron launch_only_once require_root restart_delay macos_legacy_timers sockets].each do |key| + %w[interval cron launch_only_once require_root restart_delay macos_legacy_timers].each do |key| next if (value = api_hash[key]).nil? hash[key.to_sym] = value end + %w[sockets keep_alive].each do |key| + next unless (value = api_hash[key]) + + hash[key.to_sym] = if value.is_a?(Hash) + value.transform_keys(&:to_sym) + else + value + end + end + hash end diff --git a/Library/Homebrew/test/service_spec.rb b/Library/Homebrew/test/service_spec.rb index 324a373086..85f1e8be51 100644 --- a/Library/Homebrew/test/service_spec.rb +++ b/Library/Homebrew/test/service_spec.rb @@ -14,6 +14,15 @@ describe Homebrew::Service do end end + def stub_formula_with_service_sockets(sockets_var) + stub_formula do + service do + run opt_bin/"beanstalkd" + sockets sockets_var + end + end + end + describe "#std_service_path_env" do it "returns valid std_service_path_env" do f = stub_formula do @@ -102,43 +111,44 @@ describe Homebrew::Service do end describe "#sockets" do - it "throws for missing type" do - f = stub_formula do - service do - run opt_bin/"beanstalkd" - sockets "127.0.0.1:80" - end - end + let(:sockets_type_error_message) { "Service#sockets a formatted socket definition as ://:" } - expect do - f.service.manual_command - end.to raise_error TypeError, "Service#sockets a formatted socket definition as ://:" + it "throws for missing type" do + [ + stub_formula_with_service_sockets("127.0.0.1:80"), + stub_formula_with_service_sockets({ socket: "127.0.0.1:80" }), + ].each do |f| + expect { f.service.manual_command }.to raise_error TypeError, sockets_type_error_message + end end it "throws for missing host" do - f = stub_formula do - service do - run opt_bin/"beanstalkd" - sockets "tcp://:80" - end + [ + stub_formula_with_service_sockets("tcp://:80"), + stub_formula_with_service_sockets({ socket: "tcp://:80" }), + ].each do |f| + expect { f.service.manual_command }.to raise_error TypeError, sockets_type_error_message end - - expect do - f.service.manual_command - end.to raise_error TypeError, "Service#sockets a formatted socket definition as ://:" end it "throws for missing port" do - f = stub_formula do - service do - run opt_bin/"beanstalkd" - sockets "tcp://127.0.0.1" - end + [ + stub_formula_with_service_sockets("tcp://127.0.0.1"), + stub_formula_with_service_sockets({ socket: "tcp://127.0.0.1" }), + ].each do |f| + expect { f.service.manual_command }.to raise_error TypeError, sockets_type_error_message end + end - expect do - f.service.manual_command - end.to raise_error TypeError, "Service#sockets a formatted socket definition as ://:" + it "throws for invalid host" do + [ + stub_formula_with_service_sockets("tcp://300.0.0.1:80"), + stub_formula_with_service_sockets({ socket: "tcp://300.0.0.1:80" }), + ].each do |f| + expect do + f.service.manual_command + end.to raise_error TypeError, "Service#sockets expects a valid ipv4 or ipv6 host address" + end end end @@ -259,10 +269,57 @@ describe Homebrew::Service do end it "returns valid plist with socket" do + plist_expect = <<~EOS + + + + + \tLabel + \thomebrew.mxcl.formula_name + \tLimitLoadToSessionType + \t + \t\tAqua + \t\tBackground + \t\tLoginWindow + \t\tStandardIO + \t\tSystem + \t + \tProgramArguments + \t + \t\t#{HOMEBREW_PREFIX}/opt/formula_name/bin/beanstalkd + \t + \tRunAtLoad + \t + \tSockets + \t + \t\tlisteners + \t\t + \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 + + [ + stub_formula_with_service_sockets("tcp://127.0.0.1:80"), + stub_formula_with_service_sockets({ listeners: "tcp://127.0.0.1:80" }), + ].each do |f| + plist = f.service.to_plist + expect(plist).to eq(plist_expect) + end + end + + it "returns valid plist with multiple sockets" do f = stub_formula do service do run [opt_bin/"beanstalkd", "test"] - sockets "tcp://127.0.0.1:80" + sockets socket: "tcp://0.0.0.0:80", socket_tls: "tcp://0.0.0.0:443" end end @@ -291,17 +348,24 @@ describe Homebrew::Service do \t \tSockets \t - \t\tListeners + \t\tsocket \t\t - \t\t\tSockFamily - \t\t\tIPv4v6 \t\t\tSockNodeName - \t\t\t127.0.0.1 + \t\t\t0.0.0.0 \t\t\tSockProtocol \t\t\tTCP \t\t\tSockServiceName \t\t\t80 \t\t + \t\tsocket_tls + \t\t + \t\t\tSockNodeName + \t\t\t0.0.0.0 + \t\t\tSockProtocol + \t\t\tTCP + \t\t\tSockServiceName + \t\t\t443 + \t\t \t diff --git a/docs/Formula-Cookbook.md b/docs/Formula-Cookbook.md index 2a482adf7d..ea4a00e4b6 100644 --- a/docs/Formula-Cookbook.md +++ b/docs/Formula-Cookbook.md @@ -1052,6 +1052,24 @@ The `sockets` method accepts a formatted socket definition as `://:< Please note that sockets will be accessible on IPv4 and IPv6 addresses by default. +If you only need one socket and you don't care about the name (the default is `listeners`): + +```rb +service do + run [opt_bin/"beanstalkd", "test"] + sockets "tcp://127.0.0.1:80" +end +``` + +If you need multiple sockets and/or you want to specify the name: + +```rb +service do + run [opt_bin/"beanstalkd", "test"] + sockets http: "tcp://0.0.0.0:80", https: "tcp://0.0.0.0:443" +end +``` + ### Using environment variables Homebrew has multiple levels of environment variable filtering which affects which variables are available to formulae.