From ae5e9387b95c81a7247068053a013339a6fc7762 Mon Sep 17 00:00:00 2001 From: apainintheneck Date: Sat, 23 Sep 2023 14:40:24 -0700 Subject: [PATCH 1/4] service: support multiple sockets in DSL This adds support for multiple named sockets to the service DSL. It also retains backwards compatibility with the previous DSL where you can declare one socket and it is always just named Listener by default. --- Library/Homebrew/service.rb | 43 ++++++++++++++++++--------- Library/Homebrew/test/service_spec.rb | 2 +- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/Library/Homebrew/service.rb b/Library/Homebrew/service.rb index 130e2f1e7e..8eaeac13fc 100644 --- a/Library/Homebrew/service.rb +++ b/Library/Homebrew/service.rb @@ -187,17 +187,26 @@ module Homebrew end end - sig { params(value: T.nilable(String)).returns(T.nilable(T::Hash[Symbol, String])) } + SOCKET_STRING_REGEX = %r{([a-z]+)://([a-z0-9.]+):([0-9]+)}i.freeze + + sig { + params(value: T.nilable(T.any(String, T::Hash[String, String]))) + .returns(T.nilable(T::Hash[String, 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 } + { host: host, port: port, type: type } end end @@ -410,12 +419,14 @@ 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, + SockFamily: "IPv4v6", + } + end end if @cron.present? && @run_type == RUN_TYPE_CRON @@ -511,7 +522,11 @@ module Homebrew .join(" ") end - sockets_string = "#{@sockets[:type]}://#{@sockets[:host]}:#{@sockets[:port]}" if @sockets.present? + sockets_hash = if @sockets.present? + @sockets.transform_values do |info| + "#{info[:type]}://#{info[:host]}:#{info[:port]}" + end + end { name: name_params.presence, @@ -531,7 +546,7 @@ module Homebrew restart_delay: @restart_delay, process_type: @process_type, macos_legacy_timers: @macos_legacy_timers, - sockets: sockets_string, + sockets: sockets_hash, }.compact end diff --git a/Library/Homebrew/test/service_spec.rb b/Library/Homebrew/test/service_spec.rb index 324a373086..bece2ae9d9 100644 --- a/Library/Homebrew/test/service_spec.rb +++ b/Library/Homebrew/test/service_spec.rb @@ -990,7 +990,7 @@ describe Homebrew::Service do run_type: :immediate, working_dir: "/$HOME", cron: "0 0 * * 0", - sockets: "tcp://0.0.0.0:80", + sockets: { "Listeners" => "tcp://0.0.0.0:80" }, } end From afeef33bc16e9ec2745d7d5367a51dd0b053800d Mon Sep 17 00:00:00 2001 From: apainintheneck Date: Sun, 24 Sep 2023 11:58:11 -0700 Subject: [PATCH 2/4] Add tests for multiple service sockets --- Library/Homebrew/test/service_spec.rb | 121 +++++++++++++++++++------- 1 file changed, 90 insertions(+), 31 deletions(-) diff --git a/Library/Homebrew/test/service_spec.rb b/Library/Homebrew/test/service_spec.rb index bece2ae9d9..1045dfed1d 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,33 @@ 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 - - expect do - f.service.manual_command - end.to raise_error TypeError, "Service#sockets a formatted socket definition as ://:" end end @@ -259,10 +258,59 @@ 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\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 + + [ + 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", "SocketTLS" => "tcp://0.0.0.0:443" end end @@ -291,17 +339,28 @@ 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\tSocketTLS + \t\t + \t\t\tSockFamily + \t\t\tIPv4v6 + \t\t\tSockNodeName + \t\t\t0.0.0.0 + \t\t\tSockProtocol + \t\t\tTCP + \t\t\tSockServiceName + \t\t\t443 + \t\t \t From ddfa723f778a55fbb943c3675bf43280788d8832 Mon Sep 17 00:00:00 2001 From: apainintheneck Date: Sun, 24 Sep 2023 12:04:55 -0700 Subject: [PATCH 3/4] Update service socket docs --- docs/Formula-Cookbook.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/Formula-Cookbook.md b/docs/Formula-Cookbook.md index 45cd6b5c7e..05ea59c9dd 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 "Socket" => "tcp://0.0.0.0:80", "SocketTLS" => "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. From 5fb9f90457bc403343a92c0ebd97b0d12aa922b9 Mon Sep 17 00:00:00 2001 From: apainintheneck Date: Tue, 26 Sep 2023 19:58:55 -0700 Subject: [PATCH 4/4] service: prefer symbols over strings in DSL This is more in keeping with the other DSL methods and Ruby convention along with the fact that these socket names are just used internally by launchd. --- Library/Homebrew/service.rb | 20 ++++++++++++++------ Library/Homebrew/test/service_spec.rb | 12 ++++++------ docs/Formula-Cookbook.md | 4 ++-- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/Library/Homebrew/service.rb b/Library/Homebrew/service.rb index 8eaeac13fc..3d1de39211 100644 --- a/Library/Homebrew/service.rb +++ b/Library/Homebrew/service.rb @@ -190,15 +190,15 @@ module Homebrew SOCKET_STRING_REGEX = %r{([a-z]+)://([a-z0-9.]+):([0-9]+)}i.freeze sig { - params(value: T.nilable(T.any(String, T::Hash[String, String]))) - .returns(T.nilable(T::Hash[String, T::Hash[Symbol, String]])) + 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) return @sockets if value.nil? @sockets = case value when String - { "Listeners" => value } + { listeners: value } when Hash value end.transform_values do |socket_string| @@ -580,8 +580,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)] @@ -600,12 +598,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 1045dfed1d..7f28005fb0 100644 --- a/Library/Homebrew/test/service_spec.rb +++ b/Library/Homebrew/test/service_spec.rb @@ -281,7 +281,7 @@ describe Homebrew::Service do \t \tSockets \t - \t\tListeners + \t\tlisteners \t\t \t\t\tSockFamily \t\t\tIPv4v6 @@ -299,7 +299,7 @@ describe Homebrew::Service do [ stub_formula_with_service_sockets("tcp://127.0.0.1:80"), - stub_formula_with_service_sockets({ "Listeners" => "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) @@ -310,7 +310,7 @@ describe Homebrew::Service do f = stub_formula do service do run [opt_bin/"beanstalkd", "test"] - sockets "Socket" => "tcp://0.0.0.0:80", "SocketTLS" => "tcp://0.0.0.0:443" + sockets socket: "tcp://0.0.0.0:80", socket_tls: "tcp://0.0.0.0:443" end end @@ -339,7 +339,7 @@ describe Homebrew::Service do \t \tSockets \t - \t\tSocket + \t\tsocket \t\t \t\t\tSockFamily \t\t\tIPv4v6 @@ -350,7 +350,7 @@ describe Homebrew::Service do \t\t\tSockServiceName \t\t\t80 \t\t - \t\tSocketTLS + \t\tsocket_tls \t\t \t\t\tSockFamily \t\t\tIPv4v6 @@ -1049,7 +1049,7 @@ describe Homebrew::Service do run_type: :immediate, working_dir: "/$HOME", cron: "0 0 * * 0", - sockets: { "Listeners" => "tcp://0.0.0.0:80" }, + sockets: { listeners: "tcp://0.0.0.0:80" }, } end diff --git a/docs/Formula-Cookbook.md b/docs/Formula-Cookbook.md index 05ea59c9dd..5cd70c0336 100644 --- a/docs/Formula-Cookbook.md +++ b/docs/Formula-Cookbook.md @@ -1052,7 +1052,7 @@ 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`): +If you only need one socket and you don't care about the name (the default is `listeners`): ```rb service do @@ -1066,7 +1066,7 @@ If you need multiple sockets and/or you want to specify the name: ```rb service do run [opt_bin/"beanstalkd", "test"] - sockets "Socket" => "tcp://0.0.0.0:80", "SocketTLS" => "tcp://0.0.0.0:443" + sockets http: "tcp://0.0.0.0:80", https: "tcp://0.0.0.0:443" end ```