brew/Library/Homebrew/service.rb
apainintheneck 215419daa5 service: provide backwards compatibility for socket strings
The previous PR changed how sockets were represented in the JSON
API for formulae and that would cause problems when trying to install
packages with service sockets. This provides backwards compatibility
until all users have upgraded to versions of homebrew that can deserialize
sockets hashes (maybe a couple weeks). Essentially, we store the
socket string when serializing sockets that were originally defined with
only the string parameter otherwise we serialize it to a hash.
2023-10-07 14:22:38 -07:00

645 lines
20 KiB
Ruby

# typed: true
# frozen_string_literal: true
require "ipaddr"
require "extend/on_system"
module Homebrew
# The {Service} class implements the DSL methods used in a formula's
# `service` block and stores related instance variables. Most of these methods
# also return the related instance variable when no argument is provided.
class Service
extend Forwardable
include OnSystem::MacOSAndLinux
RUN_TYPE_IMMEDIATE = :immediate
RUN_TYPE_INTERVAL = :interval
RUN_TYPE_CRON = :cron
PROCESS_TYPE_BACKGROUND = :background
PROCESS_TYPE_STANDARD = :standard
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
@run_type = RUN_TYPE_IMMEDIATE
@run_at_load = true
@environment_variables = {}
instance_eval(&block) if block
end
sig { returns(Formula) }
def f
@formula
end
sig { returns(String) }
def default_plist_name
"homebrew.mxcl.#{@formula.name}"
end
sig { returns(String) }
def plist_name
@plist_name ||= default_plist_name
end
sig { returns(String) }
def default_service_name
"homebrew.#{@formula.name}"
end
sig { returns(String) }
def service_name
@service_name ||= default_service_name
end
sig { params(macos: T.nilable(String), linux: T.nilable(String)).void }
def name(macos: nil, linux: nil)
raise TypeError, "Service#name expects at least one String" if [macos, linux].none?(String)
@plist_name = macos if macos
@service_name = linux if linux
end
sig {
params(
command: T.nilable(T.any(T::Array[String], String, Pathname)),
macos: T.nilable(T.any(T::Array[String], String, Pathname)),
linux: T.nilable(T.any(T::Array[String], String, Pathname)),
).returns(T.nilable(Array))
}
def run(command = nil, macos: nil, linux: nil)
# Save parameters for serialization
if command
@run_params = command
elsif macos || linux
@run_params = { macos: macos, linux: linux }.compact
end
command ||= on_system_conditional(macos: macos, linux: linux)
case command
when nil
@run
when String, Pathname
@run = [command]
when Array
@run = command
end
end
sig { params(path: T.nilable(T.any(String, Pathname))).returns(T.nilable(String)) }
def working_dir(path = nil)
case path
when nil
@working_dir
when String, Pathname
@working_dir = path.to_s
end
end
sig { params(path: T.nilable(T.any(String, Pathname))).returns(T.nilable(String)) }
def root_dir(path = nil)
case path
when nil
@root_dir
when String, Pathname
@root_dir = path.to_s
end
end
sig { params(path: T.nilable(T.any(String, Pathname))).returns(T.nilable(String)) }
def input_path(path = nil)
case path
when nil
@input_path
when String, Pathname
@input_path = path.to_s
end
end
sig { params(path: T.nilable(T.any(String, Pathname))).returns(T.nilable(String)) }
def log_path(path = nil)
case path
when nil
@log_path
when String, Pathname
@log_path = path.to_s
end
end
sig { params(path: T.nilable(T.any(String, Pathname))).returns(T.nilable(String)) }
def error_log_path(path = nil)
case path
when nil
@error_log_path
when String, Pathname
@error_log_path = path.to_s
end
end
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 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
end
end
sig { params(value: T.nilable(T::Boolean)).returns(T.nilable(T::Boolean)) }
def require_root(value = nil)
case value
when nil
@require_root
when true, false
@require_root = value
end
end
# Returns a `Boolean` describing if a service requires root access.
# @return [Boolean]
sig { returns(T::Boolean) }
def requires_root?
@require_root.present? && @require_root == true
end
sig { params(value: T.nilable(T::Boolean)).returns(T.nilable(T::Boolean)) }
def run_at_load(value = nil)
case value
when nil
@run_at_load
when true, false
@run_at_load = value
end
end
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)
return @sockets if value.nil?
@sockets = case value
when String
{ 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 <type>://<host>:<port>" if match.blank?
type, host, port = match.captures
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
# Returns a `Boolean` describing if a service is set to be kept alive.
# @return [Boolean]
sig { returns(T::Boolean) }
def keep_alive?
@keep_alive.present? && @keep_alive[:always] != false
end
sig { params(value: T.nilable(T::Boolean)).returns(T.nilable(T::Boolean)) }
def launch_only_once(value = nil)
case value
when nil
@launch_only_once
when true, false
@launch_only_once = value
end
end
sig { params(value: T.nilable(Integer)).returns(T.nilable(Integer)) }
def restart_delay(value = nil)
case value
when nil
@restart_delay
when Integer
@restart_delay = value
end
end
sig { params(value: T.nilable(Symbol)).returns(T.nilable(Symbol)) }
def process_type(value = nil)
case value
when nil
@process_type
when :background, :standard, :interactive, :adaptive
@process_type = value
when Symbol
raise TypeError, "Service#process_type allows: " \
"'#{PROCESS_TYPE_BACKGROUND}'/'#{PROCESS_TYPE_STANDARD}'/" \
"'#{PROCESS_TYPE_INTERACTIVE}'/'#{PROCESS_TYPE_ADAPTIVE}'"
end
end
sig { params(value: T.nilable(Symbol)).returns(T.nilable(Symbol)) }
def run_type(value = nil)
case value
when nil
@run_type
when :immediate, :interval, :cron
@run_type = value
when Symbol
raise TypeError, "Service#run_type allows: '#{RUN_TYPE_IMMEDIATE}'/'#{RUN_TYPE_INTERVAL}'/'#{RUN_TYPE_CRON}'"
end
end
sig { params(value: T.nilable(Integer)).returns(T.nilable(Integer)) }
def interval(value = nil)
case value
when nil
@interval
when Integer
@interval = value
end
end
sig { params(value: T.nilable(String)).returns(T.nilable(Hash)) }
def cron(value = nil)
case value
when nil
@cron
when String
@cron = parse_cron(T.must(value))
end
end
sig { returns(T::Hash[Symbol, T.any(Integer, String)]) }
def default_cron_values
{
Month: "*",
Day: "*",
Weekday: "*",
Hour: "*",
Minute: "*",
}
end
sig { params(cron_statement: String).returns(T::Hash[Symbol, T.any(Integer, String)]) }
def parse_cron(cron_statement)
parsed = default_cron_values
case cron_statement
when "@hourly"
parsed[:Minute] = 0
when "@daily"
parsed[:Minute] = 0
parsed[:Hour] = 0
when "@weekly"
parsed[:Minute] = 0
parsed[:Hour] = 0
parsed[:Weekday] = 0
when "@monthly"
parsed[:Minute] = 0
parsed[:Hour] = 0
parsed[:Day] = 1
when "@yearly", "@annually"
parsed[:Minute] = 0
parsed[:Hour] = 0
parsed[:Day] = 1
parsed[:Month] = 1
else
cron_parts = cron_statement.split
raise TypeError, "Service#parse_cron expects a valid cron syntax" if cron_parts.length != 5
[:Minute, :Hour, :Day, :Month, :Weekday].each_with_index do |selector, index|
parsed[selector] = Integer(cron_parts.fetch(index)) if cron_parts.fetch(index) != "*"
end
end
parsed
end
sig { params(variables: T::Hash[Symbol, String]).returns(T.nilable(T::Hash[Symbol, String])) }
def environment_variables(variables = {})
case variables
when Hash
@environment_variables = variables.transform_values(&:to_s)
end
end
sig { params(value: T.nilable(T::Boolean)).returns(T.nilable(T::Boolean)) }
def macos_legacy_timers(value = nil)
case value
when nil
@macos_legacy_timers
when true, false
@macos_legacy_timers = value
end
end
delegate [:bin, :etc, :libexec, :opt_bin, :opt_libexec, :opt_pkgshare, :opt_prefix, :opt_sbin, :var] => :@formula
sig { returns(String) }
def std_service_path_env
"#{HOMEBREW_PREFIX}/bin:#{HOMEBREW_PREFIX}/sbin:/usr/bin:/bin:/usr/sbin:/sbin"
end
sig { returns(T.nilable(T::Array[String])) }
def command
@run&.map(&:to_s)&.map { |arg| arg.start_with?("~") ? File.expand_path(arg) : arg }
end
sig { returns(T::Boolean) }
def command?
@run.present?
end
# Returns the `String` command to run manually instead of the service.
# @return [String]
sig { returns(String) }
def manual_command
vars = @environment_variables.except(:PATH)
.map { |k, v| "#{k}=\"#{v}\"" }
out = vars + T.must(command).map { |arg| Utils::Shell.sh_quote(arg) } if command?
out.join(" ")
end
# Returns a `Boolean` describing if a service is timed.
# @return [Boolean]
sig { returns(T::Boolean) }
def timed?
@run_type == RUN_TYPE_CRON || @run_type == RUN_TYPE_INTERVAL
end
# Returns a `String` plist.
# @return [String]
sig { returns(String) }
def to_plist
# command needs to be first because it initializes all other values
base = {
Label: plist_name,
ProgramArguments: command,
RunAtLoad: @run_at_load == 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?
base[:ProcessType] = @process_type.to_s.capitalize if @process_type.present?
base[:StartInterval] = @interval if @interval.present? && @run_type == RUN_TYPE_INTERVAL
base[:WorkingDirectory] = File.expand_path(@working_dir) if @working_dir.present?
base[:RootDirectory] = File.expand_path(@root_dir) if @root_dir.present?
base[:StandardInPath] = File.expand_path(@input_path) if @input_path.present?
base[:StandardOutPath] = File.expand_path(@log_path) if @log_path.present?
base[:StandardErrorPath] = File.expand_path(@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] = {}
@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
base[:StartCalendarInterval] = @cron.reject { |_, value| value == "*" }
end
# Adding all session types has as the primary effect that if you initialise it through e.g. a Background session
# and you later "physically" sign in to the owning account (Aqua session), things shouldn't flip out.
# Also, we're not checking @process_type here because that is used to indicate process priority and not
# necessarily if it should run in a specific session type. Like database services could run with ProcessType
# Interactive so they have no resource limitations enforced upon them, but they aren't really interactive in the
# general sense.
base[:LimitLoadToSessionType] = %w[Aqua Background LoginWindow StandardIO System]
base.to_plist
end
# Returns a `String` systemd unit.
# @return [String]
sig { returns(String) }
def to_systemd_unit
unit = <<~EOS
[Unit]
Description=Homebrew generated unit for #{@formula.name}
[Install]
WantedBy=default.target
[Service]
EOS
# command needs to be first because it initializes all other values
cmd = command&.map { |arg| Utils::Shell.sh_quote(arg) }
&.join(" ")
options = []
options << "Type=#{(@launch_only_once == true) ? "oneshot" : "simple"}"
options << "ExecStart=#{cmd}"
options << "Restart=always" if @keep_alive.present? && @keep_alive[:always].present?
options << "RestartSec=#{restart_delay}" if @restart_delay.present?
options << "WorkingDirectory=#{File.expand_path(@working_dir)}" if @working_dir.present?
options << "RootDirectory=#{File.expand_path(@root_dir)}" if @root_dir.present?
options << "StandardInput=file:#{File.expand_path(@input_path)}" if @input_path.present?
options << "StandardOutput=append:#{File.expand_path(@log_path)}" if @log_path.present?
options << "StandardError=append:#{File.expand_path(@error_log_path)}" if @error_log_path.present?
options += @environment_variables.map { |k, v| "Environment=\"#{k}=#{v}\"" } if @environment_variables.present?
unit + options.join("\n")
end
# Returns a `String` systemd unit timer.
# @return [String]
sig { returns(String) }
def to_systemd_timer
timer = <<~EOS
[Unit]
Description=Homebrew generated timer for #{@formula.name}
[Install]
WantedBy=timers.target
[Timer]
Unit=#{service_name}
EOS
options = []
options << "Persistent=true" if @run_type == RUN_TYPE_CRON
options << "OnUnitActiveSec=#{@interval}" if @run_type == RUN_TYPE_INTERVAL
if @run_type == RUN_TYPE_CRON
minutes = (@cron[:Minute] == "*") ? "*" : format("%02d", @cron[:Minute])
hours = (@cron[:Hour] == "*") ? "*" : format("%02d", @cron[:Hour])
options << "OnCalendar=#{@cron[:Weekday]}-*-#{@cron[:Month]}-#{@cron[:Day]} #{hours}:#{minutes}:00"
end
timer + options.join("\n")
end
# Prepare the service hash for inclusion in the formula API JSON.
sig { returns(Hash) }
def serialize
name_params = {
macos: (plist_name if plist_name != default_plist_name),
linux: (service_name if service_name != default_service_name),
}.compact
return { name: name_params }.compact_blank if @run_params.blank?
cron_string = if @cron.present?
[:Minute, :Hour, :Day, :Month, :Weekday]
.map { |key| @cron[key].to_s }
.join(" ")
end
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,
run: @run_params,
run_type: @run_type,
interval: @interval,
cron: cron_string,
keep_alive: @keep_alive,
launch_only_once: @launch_only_once,
require_root: @require_root,
environment_variables: @environment_variables.presence,
working_dir: @working_dir,
root_dir: @root_dir,
input_path: @input_path,
log_path: @log_path,
error_log_path: @error_log_path,
restart_delay: @restart_delay,
process_type: @process_type,
macos_legacy_timers: @macos_legacy_timers,
sockets: sockets_var,
}.compact
end
# Turn the service API hash values back into what is expected by the formula DSL.
sig { params(api_hash: Hash).returns(Hash) }
def self.deserialize(api_hash)
hash = {}
hash[:name] = api_hash["name"].transform_keys(&:to_sym) if api_hash.key?("name")
# The service hash might not have a "run" command if it only documents
# an existing service file with the "name" command.
return hash unless api_hash.key?("run")
hash[:run] =
case api_hash["run"]
when String
replace_placeholders(api_hash["run"])
when Array
api_hash["run"].map(&method(:replace_placeholders))
when Hash
api_hash["run"].to_h do |key, elem|
run_cmd = if elem.is_a?(Array)
elem.map(&method(:replace_placeholders))
else
replace_placeholders(elem)
end
[key.to_sym, run_cmd]
end
else
raise ArgumentError, "Unexpected run command: #{api_hash["run"]}"
end
if api_hash.key?("environment_variables")
hash[:environment_variables] = api_hash["environment_variables"].to_h do |key, value|
[key.to_sym, replace_placeholders(value)]
end
end
%w[run_type process_type].each do |key|
next unless (value = api_hash[key])
hash[key.to_sym] = value.to_sym
end
%w[working_dir root_dir input_path log_path error_log_path].each do |key|
next unless (value = api_hash[key])
hash[key.to_sym] = replace_placeholders(value)
end
%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
# Replace API path placeholders with local paths.
sig { params(string: String).returns(String) }
def self.replace_placeholders(string)
string.gsub(HOMEBREW_PREFIX_PLACEHOLDER, HOMEBREW_PREFIX)
.gsub(HOMEBREW_CELLAR_PLACEHOLDER, HOMEBREW_CELLAR)
.gsub(HOMEBREW_HOME_PLACEHOLDER, Dir.home)
end
end
end