brew/Library/Homebrew/service.rb
apainintheneck db8d488db4 service: add info to API json and load from api
As a part of serializing the hash, certain path
placeholders are used for the HOMEBREW_PREFIX and $HOME
directories. Beyond that certain elements need to be
turned back into strings like cron and sockets and symbols
need to be preserved as well.

The run command accepts either an arg or kwargs so it
has to be treated specially here.
2023-03-19 00:09:10 -07:00

593 lines
19 KiB
Ruby

# typed: true
# frozen_string_literal: true
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 T::Sig
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 = {}
@service_block = block
end
sig { returns(Formula) }
def f
@formula
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
@run_params ||= command || { macos: macos, linux: linux }.compact
command ||= on_system_conditional(macos: macos, linux: linux)
case T.unsafe(command)
when nil
@run
when String, Pathname
@run = [command]
when Array
@run = command
else
raise TypeError, "Service#run expects an Array"
end
end
sig { params(path: T.nilable(T.any(String, Pathname))).returns(T.nilable(String)) }
def working_dir(path = nil)
case T.unsafe(path)
when nil
@working_dir
when String, Pathname
@working_dir = path.to_s
else
raise TypeError, "Service#working_dir expects a String"
end
end
sig { params(path: T.nilable(T.any(String, Pathname))).returns(T.nilable(String)) }
def root_dir(path = nil)
case T.unsafe(path)
when nil
@root_dir
when String, Pathname
@root_dir = path.to_s
else
raise TypeError, "Service#root_dir expects a String or Pathname"
end
end
sig { params(path: T.nilable(T.any(String, Pathname))).returns(T.nilable(String)) }
def input_path(path = nil)
case T.unsafe(path)
when nil
@input_path
when String, Pathname
@input_path = path.to_s
else
raise TypeError, "Service#input_path expects a String or Pathname"
end
end
sig { params(path: T.nilable(T.any(String, Pathname))).returns(T.nilable(String)) }
def log_path(path = nil)
case T.unsafe(path)
when nil
@log_path
when String, Pathname
@log_path = path.to_s
else
raise TypeError, "Service#log_path expects a String"
end
end
sig { params(path: T.nilable(T.any(String, Pathname))).returns(T.nilable(String)) }
def error_log_path(path = nil)
case T.unsafe(path)
when nil
@error_log_path
when String, Pathname
@error_log_path = path.to_s
else
raise TypeError, "Service#error_log_path expects a String"
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 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 or Hash"
end
end
sig { params(value: T.nilable(T::Boolean)).returns(T.nilable(T::Boolean)) }
def require_root(value = nil)
case T.unsafe(value)
when nil
@require_root
when true, false
@require_root = value
else
raise TypeError, "Service#require_root expects a Boolean"
end
end
# Returns a `Boolean` describing if a service requires root access.
# @return [Boolean]
sig { returns(T::Boolean) }
def requires_root?
eval_service_block
@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 T.unsafe(value)
when nil
@run_at_load
when true, false
@run_at_load = value
else
raise TypeError, "Service#run_at_load expects a Boolean"
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
# Returns a `Boolean` describing if a service is set to be kept alive.
# @return [Boolean]
sig { returns(T::Boolean) }
def keep_alive?
eval_service_block
@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 T.unsafe(value)
when nil
@launch_only_once
when true, false
@launch_only_once = value
else
raise TypeError, "Service#launch_only_once expects a Boolean"
end
end
sig { params(value: T.nilable(Integer)).returns(T.nilable(Integer)) }
def restart_delay(value = nil)
case T.unsafe(value)
when nil
@restart_delay
when Integer
@restart_delay = value
else
raise TypeError, "Service#restart_delay expects an Integer"
end
end
sig { params(value: T.nilable(Symbol)).returns(T.nilable(Symbol)) }
def process_type(value = nil)
case T.unsafe(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}'"
else
raise TypeError, "Service#process_type expects a Symbol"
end
end
sig { params(value: T.nilable(Symbol)).returns(T.nilable(Symbol)) }
def run_type(value = nil)
case T.unsafe(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}'"
else
raise TypeError, "Service#run_type expects a Symbol"
end
end
sig { params(value: T.nilable(Integer)).returns(T.nilable(Integer)) }
def interval(value = nil)
case T.unsafe(value)
when nil
@interval
when Integer
@interval = value
else
raise TypeError, "Service#interval expects an Integer"
end
end
sig { params(value: T.nilable(String)).returns(T.nilable(Hash)) }
def cron(value = nil)
case T.unsafe(value)
when nil
@cron
when String
@cron = parse_cron(T.must(value))
else
raise TypeError, "Service#cron expects a String"
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 T.unsafe(variables)
when Hash
@environment_variables = variables.transform_values(&:to_s)
else
raise TypeError, "Service#environment_variables expects a hash"
end
end
sig { params(value: T.nilable(T::Boolean)).returns(T.nilable(T::Boolean)) }
def macos_legacy_timers(value = nil)
case T.unsafe(value)
when nil
@macos_legacy_timers
when true, false
@macos_legacy_timers = value
else
raise TypeError, "Service#macos_legacy_timers expects a Boolean"
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
eval_service_block
@run&.map(&:to_s)
end
# Returns the `String` command to run manually instead of the service.
# @return [String]
sig { returns(String) }
def manual_command
eval_service_block
vars = @environment_variables.except(:PATH)
.map { |k, v| "#{k}=\"#{v}\"" }
cmd = command
out = vars + cmd if cmd.present?
out.join(" ")
end
# Returns a `Boolean` describing if a service is timed.
# @return [Boolean]
sig { returns(T::Boolean) }
def timed?
eval_service_block
@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: @formula.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] = @working_dir if @working_dir.present?
base[:RootDirectory] = @root_dir if @root_dir.present?
base[:StandardInPath] = @input_path if @input_path.present?
base[:StandardOutPath] = @log_path if @log_path.present?
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
# 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&.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=#{@working_dir}" if @working_dir.present?
options << "RootDirectory=#{@root_dir}" if @root_dir.present?
options << "StandardInput=file:#{@input_path}" if @input_path.present?
options << "StandardOutput=append:#{@log_path}" if @log_path.present?
options << "StandardError=append:#{@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=#{@formula.service_name}
EOS
eval_service_block
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
# Only evaluate the service block once.
sig { void }
def eval_service_block
return if @eval_service_block
instance_eval(&@service_block)
@eval_service_block = true
end
# Recursively prepare the service hash for inclusion in the formula API JSON.
# - Replace each Symbol with a String prefixed by ":"
# - Replace the local HOMEBREW_PREFIX with "$HOMEBREW_PREFIX"
# - Replace the local home directory with "$HOME"
def serialize(elem = to_h)
case elem
when String, Pathname
elem.to_s.gsub(HOMEBREW_PREFIX, HOMEBREW_PREFIX_PLACEHOLDER)
.gsub(Dir.home, "$HOME")
when Symbol
elem.inspect
when Array
elem.map { |value| serialize(value) }
when Hash
elem.to_h do |key, value|
key = key.inspect if key.is_a?(Symbol)
[key, serialize(value)]
end
else
elem
end
end
# Recursively turn the service API hash values back into what is expected by the formula DSL.
# - Replace each String prefixed with ":" with a Symbol
# - Replace "$HOMEBREW_PREFIX" with the local HOMEBREW_PREFIX environment variable
# - Replace "$HOME" with the local home directory
def self.deserialize(elem)
case elem
when String
return T.must(elem[1..]).to_sym if elem.start_with?(":")
elem.gsub(HOMEBREW_PREFIX_PLACEHOLDER, HOMEBREW_PREFIX)
.gsub("$HOME", Dir.home)
when Array
elem.map { |value| deserialize(value) }
when Hash
elem.to_h do |key, value|
key = key[1..].to_sym if key.start_with?(":")
[key, deserialize(value)]
end
else
elem
end
end
sig { returns(Hash) }
def to_h
eval_service_block
cron_string = if @cron.present?
[:Minute, :Hour, :Day, :Month, :Weekday]
.map { |key| @cron[key].to_s }
.join(" ")
end
sockets_string = "#{@sockets[:type]}://#{@sockets[:host]}:#{@sockets[:port]}" if @sockets.present?
{
"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_string,
}.compact
end
end
end