diff --git a/Library/Homebrew/formula.rb b/Library/Homebrew/formula.rb index 50b236e40b..2ddc078de2 100644 --- a/Library/Homebrew/formula.rb +++ b/Library/Homebrew/formula.rb @@ -14,6 +14,7 @@ require "build_options" require "formulary" require "software_spec" require "livecheck" +require "service" require "install_renamed" require "pkg_version" require "keg" @@ -391,6 +392,11 @@ class Formula # @see .livecheckable? delegate livecheckable?: :"self.class" + # Is a service specification defined for the software? + # @!method service? + # @see .service? + delegate service?: :"self.class" + # The version for the currently active {SoftwareSpec}. # The version is autodetected from the URL and/or tag so only needs to be # declared if it cannot be autodetected correctly. @@ -962,6 +968,13 @@ class Formula prefix/"#{plist_name}.plist" end + # The service specification of the software. + def service + return unless service? + + Homebrew::Service.new(self, &self.class.service) + end + # @private delegate plist_manual: :"self.class" @@ -2382,6 +2395,13 @@ class Formula @livecheckable == true end + # Whether a service specification is defined or not. + # It returns true when a service block is present in the {Formula} and + # false otherwise, and is used by service. + def service? + @service_block.present? + end + # The `:startup` attribute set by {.plist_options}. # @private attr_reader :plist_startup @@ -2846,6 +2866,21 @@ class Formula @livecheck.instance_eval(&block) end + # @!attribute [w] service + # Service can be used to define services. + # This method evaluates the DSL specified in the service block of the + # {Formula} (if it exists) and sets the instance variables of a Service + # object accordingly. This is used by `brew install` to generate a plist. + # + #
service do
+    #   run [opt_bin/"foo"]
+    # end
+ def service(&block) + return @service_block unless block + + @service_block = block + end + # Defines whether the {Formula}'s bottle can be used on the given Homebrew # installation. # diff --git a/Library/Homebrew/formula_installer.rb b/Library/Homebrew/formula_installer.rb index 0d5bfd8074..160448f984 100644 --- a/Library/Homebrew/formula_installer.rb +++ b/Library/Homebrew/formula_installer.rb @@ -21,6 +21,7 @@ require "find" require "utils/spdx" require "deprecate_disable" require "unlink" +require "service" # Installer for a formula. # @@ -774,7 +775,7 @@ class FormulaInstaller ohai "Finishing up" if verbose? - install_plist + install_service keg = Keg.new(formula.prefix) link(keg) @@ -1009,13 +1010,25 @@ class FormulaInstaller end sig { void } - def install_plist - return unless formula.plist + def install_service + if formula.service? && formula.plist + ofail "Formula specified both service and plist" + return + end - formula.plist_path.atomic_write(formula.plist) - formula.plist_path.chmod 0644 + plist = if formula.service? + formula.service.to_plist + elsif formula.plist + formula.plist + end + + return unless plist + + plist_path = formula.plist_path + plist_path.atomic_write(plist) + plist_path.chmod 0644 log = formula.var/"log" - log.mkpath if formula.plist.include? log.to_s + log.mkpath if plist.include? log.to_s rescue Exception => e # rubocop:disable Lint/RescueException ofail "Failed to install plist file" odebug e, e.backtrace diff --git a/Library/Homebrew/service.rb b/Library/Homebrew/service.rb new file mode 100644 index 0000000000..88f88abaf8 --- /dev/null +++ b/Library/Homebrew/service.rb @@ -0,0 +1,145 @@ +# typed: true +# frozen_string_literal: true + +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 + + RUN_TYPE_IMMEDIATE = "immediate" + RUN_TYPE_INTERVAL = "interval" + RUN_TYPE_CRON = "cron" + + # sig { params(formula: Formula).void } + def initialize(formula, &block) + @formula = formula + @run_type = RUN_TYPE_IMMEDIATE + @environment_variables = {} + @service_block = block + end + + sig { params(command: T.nilable(T.any(T::Array[String], String, Pathname))).returns(T.nilable(Array)) } + def run(command = nil) + 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 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::Boolean)).returns(T.nilable(T::Boolean)) } + def keep_alive(value = nil) + case T.unsafe(value) + when nil + @keep_alive + when true, false + @keep_alive = value + else + raise TypeError, "Service#keep_alive expects a Boolean" + end + end + + sig { params(type: T.nilable(T.any(String, Symbol))).returns(T.nilable(String)) } + def run_type(type = nil) + case T.unsafe(type) + when nil + @run_type + when "immediate", :immediate + @run_type = type.to_s + when RUN_TYPE_INTERVAL, RUN_TYPE_CRON + raise TypeError, "Service#run_type does not support timers" + when String + raise TypeError, "Service#run_type allows: '#{RUN_TYPE_IMMEDIATE}'/'#{RUN_TYPE_INTERVAL}'/'#{RUN_TYPE_CRON}'" + else + raise TypeError, "Service#run_type expects a string" + end + end + + sig { params(variables: T.nilable(T::Hash[String, String])).returns(T.nilable(T::Hash[String, String])) } + def environment_variables(variables = {}) + case T.unsafe(variables) + when nil + @environment_variables + when Hash + @environment_variables = variables + else + raise TypeError, "Service#environment_variables expects a hash" + end + end + + delegate [:bin, :var, :etc, :opt_bin, :opt_sbin, :opt_prefix] => :@formula + + sig { returns(String) } + def std_service_path_env + "#{HOMEBREW_PREFIX}/bin:#{HOMEBREW_PREFIX}/sbin:/usr/bin:/bin:/usr/sbin:/sbin" + end + + # Returns a `String` plist. + # @return [String] + sig { returns(String) } + def to_plist + instance_eval(&@service_block) + + clean_command = @run.select { |i| i.is_a?(Pathname) } + .map(&:to_s) + + base = { + Label: @formula.plist_name, + RunAtLoad: @run_type == RUN_TYPE_IMMEDIATE, + ProgramArguments: clean_command, + } + + base[:KeepAlive] = @keep_alive if @keep_alive == true + base[:WorkingDirectory] = @working_dir if @working_dir.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? + + base.to_plist + end + end +end diff --git a/Library/Homebrew/test/formula_installer_spec.rb b/Library/Homebrew/test/formula_installer_spec.rb index 742f6a2d81..31a3a7c273 100644 --- a/Library/Homebrew/test/formula_installer_spec.rb +++ b/Library/Homebrew/test/formula_installer_spec.rb @@ -218,4 +218,79 @@ describe FormulaInstaller do formula_installer.caveats end end + + describe "#install_service" do + it "works if plist is set" do + formula = Testball.new + path = formula.plist_path + formula.prefix.mkpath + + expect(formula).to receive(:plist).twice.and_return("PLIST") + expect(formula).to receive(:plist_path).and_call_original + + installer = described_class.new(formula) + expect { + installer.install_service + }.not_to output(/Error: Failed to install plist file/).to_stderr + + expect(path).to exist + end + + it "works if service is set" do + formula = Testball.new + path = formula.plist_path + service = Homebrew::Service.new(formula) + formula.prefix.mkpath + + expect(formula).to receive(:plist).and_return(nil) + expect(formula).to receive(:service?).twice.and_return(true) + expect(formula).to receive(:service).and_return(service) + expect(formula).to receive(:plist_path).and_call_original + + expect(service).to receive(:to_plist).and_return("plist") + + installer = described_class.new(formula) + expect { + installer.install_service + }.not_to output(/Error: Failed to install plist file/).to_stderr + + expect(path).to exist + end + + it "returns without definition" do + formula = Testball.new + path = formula.plist_path + formula.prefix.mkpath + + expect(formula).to receive(:plist).and_return(nil) + expect(formula).to receive(:service?).twice.and_return(nil) + expect(formula).not_to receive(:plist_path) + + installer = described_class.new(formula) + expect { + installer.install_service + }.not_to output(/Error: Failed to install plist file/).to_stderr + + expect(path).not_to exist + end + + it "errors with duplicate definition" do + formula = Testball.new + path = formula.plist_path + formula.prefix.mkpath + + expect(formula).to receive(:plist).and_return("plist") + expect(formula).to receive(:service?).and_return(true) + expect(formula).not_to receive(:service) + expect(formula).not_to receive(:plist_path) + + installer = described_class.new(formula) + expect { + installer.install_service + }.to output("Error: Formula specified both service and plist\n").to_stderr + + expect(Homebrew).to have_failed + expect(path).not_to exist + end + end end diff --git a/Library/Homebrew/test/formula_spec.rb b/Library/Homebrew/test/formula_spec.rb index d320e02d9b..2cdb36c091 100644 --- a/Library/Homebrew/test/formula_spec.rb +++ b/Library/Homebrew/test/formula_spec.rb @@ -701,6 +701,33 @@ describe Formula do end end + specify "#service" do + f = formula do + url "https://brew.sh/test-1.0.tbz" + end + + f.class.service do + run [opt_bin/"beanstalkd"] + run_type :immediate + error_log_path var/"log/beanstalkd.error.log" + log_path var/"log/beanstalkd.log" + working_dir var + keep_alive true + end + expect(f.service).not_to eq(nil) + end + + specify "service uses simple run" do + f = formula do + url "https://brew.sh/test-1.0.tbz" + service do + run opt_bin/"beanstalkd" + end + end + + expect(f.service).not_to eq(nil) + end + specify "dependencies" do f1 = formula "f1" do url "f1-1.0" diff --git a/Library/Homebrew/test/service_spec.rb b/Library/Homebrew/test/service_spec.rb new file mode 100644 index 0000000000..aad43d0aea --- /dev/null +++ b/Library/Homebrew/test/service_spec.rb @@ -0,0 +1,66 @@ +# typed: false +# frozen_string_literal: true + +require "formula" +require "service" + +describe Homebrew::Service do + let(:klass) do + Class.new(Formula) do + url "https://brew.sh/test-1.0.tbz" + end + end + let(:name) { "formula_name" } + let(:path) { Formulary.core_path(name) } + let(:spec) { :stable } + let(:f) { klass.new(name, path, spec) } + + describe "#to_plist" do + it "returns valid plist" do + f.class.service do + run opt_bin/"beanstalkd" + run_type :immediate + environment_variables PATH: std_service_path_env + error_log_path var/"log/beanstalkd.error.log" + log_path var/"log/beanstalkd.log" + working_dir var + keep_alive true + end + + plist = f.service.to_plist + expect(plist).to include("Label") + expect(plist).to include("homebrew.mxcl.#{name}") + expect(plist).to include("KeepAlive") + expect(plist).to include("RunAtLoad") + expect(plist).to include("ProgramArguments") + expect(plist).to include("#{HOMEBREW_PREFIX}/opt/#{name}/bin/beanstalkd") + expect(plist).to include("WorkingDirectory") + expect(plist).to include("#{HOMEBREW_PREFIX}/var") + expect(plist).to include("StandardOutPath") + expect(plist).to include("#{HOMEBREW_PREFIX}/var/log/beanstalkd.log") + expect(plist).to include("StandardErrorPath") + expect(plist).to include("#{HOMEBREW_PREFIX}/var/log/beanstalkd.error.log") + expect(plist).to include("EnvironmentVariables") + expect(plist).to include("PATH") + expect(plist).to include("#{HOMEBREW_PREFIX}/bin:#{HOMEBREW_PREFIX}/sbin:/usr/bin:/bin:") + end + + it "returns valid partial plist" do + f.class.service do + run bin/"beanstalkd" + run_type :immediate + end + + plist = f.service.to_plist + expect(plist).to include("homebrew.mxcl.#{name}") + expect(plist).to include("Label") + expect(plist).not_to include("KeepAlive") + expect(plist).to include("RunAtLoad") + expect(plist).to include("ProgramArguments") + expect(plist).not_to include("WorkingDirectory") + expect(plist).not_to include("StandardOutPath") + expect(plist).not_to include("StandardErrorPath") + expect(plist).not_to include("EnvironmentVariables") + end + end +end