brew/Library/Homebrew/service.rb
apainintheneck 1111706378 service: add custom service name DSL
The main thing is that this DSL allows us to provide an
interface that can be serialized to the JSON API.

Changes:
- Homebrew::Service
  - Adds `#service_name` and `#plist_name` methods
    - Each is now included in the `#serialize` method as well
  - Eval block on instantiation
    - Before we lazy evaluated this but the cost is not significant
      and it complicated the code a bunch. This only gets called
      during install, when evaluating caveats and in the `brew service`
      command. It skips this evaluation if the service block isn't there.
  - Add `#command?` helper to avoid `#command.blank?` and `#command.present?`
- Formula
  - `#service` now returns a service whenever it's called. This call is
    hidden behind a call to `#service?` most of the time anyway so this
    should be fine.
  - `#plist_name` and `#service_name` now call the methods of the same name
    on the service class. This should have already been in the service object
    to begin with and keeping these methods here helps preserve backwards
    compatibility with people who were overwriting these methods before.
- Caveats
  - Prefer `service#command?`
  - Add helpers for checking on service commands
    - This duplicates some of the work in `brew services`. Maybe we should
      merge that repo in at some point.
  - Check for installed service at `#plist_name` or `#service_name`. I think
    this should be used instead of `Keg#plist_installed?` which checked for any plist file.
    We should think about deprecating `#plist_installed?` in the future.
  - Stop using `ps aux | grep #{formula.plist_name}` to check for service files
    because it was inaccurate (it always returns true on my machine) because the grep
    process is started before the ps process.
  - Note: The behavior is the same as it was before. This means that caveats
    only show up for custom service files on install or if they're already installed.
    Otherwise it won't show up in `brew info`. This is because it has to check
    first if the service file has been installed.
- Utils::Service
  - Add utils for evaluating if a service is installed and running. This duplicates
    some of the work already found in `brew services`. We should seriously consider
    merging `brew services` with the main brew repo in the future since it's already
    tightly coupled to the code in the main repo.
- Formulary.load_formula_from_api
  - Be more explicit about which types can be deserialized into run params since
    it is now possible for run params to be nil.
- Update and add tests
2023-05-13 14:53:01 -07:00

655 lines
20 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 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 { params(name: T.nilable(String)).returns(String) }
def plist_name(name = nil)
case T.unsafe(name)
when nil
@plist_name ||= default_plist_name
when String
@plist_name = name
else
raise TypeError, "Service#plist_name expects a String"
end
end
sig { returns(String) }
def default_service_name
"homebrew.#{@formula.name}"
end
sig { params(name: T.nilable(String)).returns(String) }
def service_name(name = nil)
case T.unsafe(name)
when nil
@service_name ||= default_service_name
when String
@service_name = name
else
raise TypeError, "Service#service_name expects a String"
end
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 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?
@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?
@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
@run&.map(&:to_s)
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 + command 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] = @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=#{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
custom_plist_name = plist_name if plist_name != default_plist_name
custom_service_name = service_name if service_name != default_service_name
unless command?
return {
plist_name: custom_plist_name,
service_name: custom_service_name,
}.compact
end
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?
{
plist_name: custom_plist_name,
service_name: custom_service_name,
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
# 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 = {}
%w[plist_name service_name].each do |key|
next if (value = api_hash[key]).nil?
hash[key.to_sym] = value
end
# The service hash might not have a "run" command if it only documents
# an existing service file with "plist_name" or "service_name".
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
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)]
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 sockets].each do |key|
next if (value = api_hash[key]).nil?
hash[key.to_sym] = value
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_HOME_PLACEHOLDER, Dir.home)
end
end
end