From 4427fa283faca42c9051c22d30ca55e4f9de3b32 Mon Sep 17 00:00:00 2001 From: Sean Molenaar Date: Fri, 11 Dec 2020 23:14:50 +0100 Subject: [PATCH 1/3] formula: allow plists to be generated --- Library/Homebrew/formula.rb | 34 ++++ Library/Homebrew/formula_installer.rb | 25 ++- Library/Homebrew/service.rb | 181 ++++++++++++++++++ .../Homebrew/test/formula_installer_spec.rb | 75 ++++++++ Library/Homebrew/test/formula_spec.rb | 32 ++++ Library/Homebrew/test/service_spec.rb | 68 +++++++ 6 files changed, 409 insertions(+), 6 deletions(-) create mode 100644 Library/Homebrew/service.rb create mode 100644 Library/Homebrew/test/service_spec.rb diff --git a/Library/Homebrew/formula.rb b/Library/Homebrew/formula.rb index bd49409185..09a237b74b 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,16 @@ class Formula # @see .livecheckable? delegate livecheckable?: :"self.class" + # The service specification for the software. + # @!method service + # @see .service= + delegate service: :"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. @@ -2382,6 +2393,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.present? + end + # The `:startup` attribute set by {.plist_options}. # @private attr_reader :plist_startup @@ -2846,6 +2864,22 @@ 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) + @service ||= Homebrew::Service.new(self) + return @service unless block + + @service.instance_eval(&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..32c36aa71f --- /dev/null +++ b/Library/Homebrew/service.rb @@ -0,0 +1,181 @@ +# 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 + + RUN_TYPE_IMMEDIATE = "immediate" + RUN_TYPE_INTERVAL = "interval" + RUN_TYPE_CRON = "cron" + + # sig { params(formula: Formula).void } + def initialize(formula) + @formula = formula + @run_type = RUN_TYPE_IMMEDIATE + @environment_variables = {} + 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 + + # The directory where the formula's variable files should be installed. + # This directory is not inside the `HOMEBREW_CELLAR` so it persists + # across upgrades. + sig { returns(Pathname) } + def var + HOMEBREW_PREFIX/"var" + end + + # The directory where the formula's configuration files should be installed. + # Anything using `etc.install` will not overwrite other files on e.g. upgrades + # but will write a new file named `*.default`. + # This directory is not inside the `HOMEBREW_CELLAR` so it persists + # across upgrades. + sig { returns(Pathname) } + def etc + HOMEBREW_PREFIX/"etc" + end + + # The directory where the formula's binaries should be installed. + # This is symlinked into `HOMEBREW_PREFIX` after installation or with + # `brew link` for formulae that are not keg-only. + sig { returns(Pathname) } + def opt_bin + opt_prefix/"bin" + end + + # The directory where the formula's binaries should be installed. + # This is symlinked into `HOMEBREW_PREFIX` after installation or with + # `brew link` for formulae that are not keg-only. + sig { returns(Pathname) } + def opt_sbin + opt_prefix/"sbin" + end + + # A stable path for this formula, when installed. Contains the formula name + # but no version number. Only the active version will be linked here if + # multiple versions are installed. + sig { returns(Pathname) } + def opt_prefix + HOMEBREW_PREFIX/"opt/#{@formula.name}" + end + + 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 + 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 937aeb4d59..c1165deb5c 100644 --- a/Library/Homebrew/test/formula_spec.rb +++ b/Library/Homebrew/test/formula_spec.rb @@ -701,6 +701,38 @@ describe Formula do end end + specify "#service" do + f = formula do + url "https://brew.sh/test-1.0.tbz" + 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 + end + + expect(f.service.run).to eq([HOMEBREW_PREFIX/"opt/bin/beanstalkd"]) + expect(f.service.error_log_path).to eq("#{HOMEBREW_PREFIX}/var/log/beanstalkd.error.log") + expect(f.service.log_path).to eq("#{HOMEBREW_PREFIX}/var/log/beanstalkd.log") + expect(f.service.working_dir).to eq("#{HOMEBREW_PREFIX}/var") + expect(f.service.keep_alive).to eq(true) + expect(f.service.run_type).to eq("immediate") + 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.run).to eq([HOMEBREW_PREFIX/"opt/bin/beanstalkd"]) + 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..f6d852814e --- /dev/null +++ b/Library/Homebrew/test/service_spec.rb @@ -0,0 +1,68 @@ +# 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) } + + let(:service) { described_class.new(f) } + + describe "#to_plist" do + it "returns valid plist" do + service.instance_eval 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 = 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 + service.instance_eval do + run ["#{HOMEBREW_PREFIX}/bin/beanstalkd"] + run_type :immediate + end + + plist = 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 From b927ecfd8512f0fa248bd9766faead33d5d4c841 Mon Sep 17 00:00:00 2001 From: Sean Molenaar Date: Thu, 8 Apr 2021 10:06:45 +0200 Subject: [PATCH 2/3] Fix formulae method loading Co-authored-by: nandahkrishna --- Library/Homebrew/formula.rb | 19 ++++----- Library/Homebrew/service.rb | 57 +++++++-------------------- Library/Homebrew/test/formula_spec.rb | 25 +++++------- Library/Homebrew/test/service_spec.rb | 14 +++---- 4 files changed, 40 insertions(+), 75 deletions(-) diff --git a/Library/Homebrew/formula.rb b/Library/Homebrew/formula.rb index 09a237b74b..1605605197 100644 --- a/Library/Homebrew/formula.rb +++ b/Library/Homebrew/formula.rb @@ -392,11 +392,6 @@ class Formula # @see .livecheckable? delegate livecheckable?: :"self.class" - # The service specification for the software. - # @!method service - # @see .service= - delegate service: :"self.class" - # Is a service specification defined for the software? # @!method service? # @see .service? @@ -973,6 +968,13 @@ class Formula prefix/"#{plist_name}.plist" end + # The service specification of the software. + def service + return Homebrew::Service.new(self, &self.class.service) if service? + + nil + end + # @private delegate plist_manual: :"self.class" @@ -2397,7 +2399,7 @@ class Formula # It returns true when a service block is present in the {Formula} and # false otherwise, and is used by service. def service? - @service.present? + @service_block.present? end # The `:startup` attribute set by {.plist_options}. @@ -2874,10 +2876,9 @@ class Formula # run [opt_bin/"foo"] # end def service(&block) - @service ||= Homebrew::Service.new(self) - return @service unless block + return @service_block unless block - @service.instance_eval(&block) + @service_block = block end # Defines whether the {Formula}'s bottle can be used on the given Homebrew diff --git a/Library/Homebrew/service.rb b/Library/Homebrew/service.rb index 32c36aa71f..fc826c73c6 100644 --- a/Library/Homebrew/service.rb +++ b/Library/Homebrew/service.rb @@ -7,16 +7,18 @@ module Homebrew # 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) + 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)) } @@ -109,47 +111,14 @@ module Homebrew end end - # The directory where the formula's variable files should be installed. - # This directory is not inside the `HOMEBREW_CELLAR` so it persists - # across upgrades. - sig { returns(Pathname) } - def var - HOMEBREW_PREFIX/"var" - end - - # The directory where the formula's configuration files should be installed. - # Anything using `etc.install` will not overwrite other files on e.g. upgrades - # but will write a new file named `*.default`. - # This directory is not inside the `HOMEBREW_CELLAR` so it persists - # across upgrades. - sig { returns(Pathname) } - def etc - HOMEBREW_PREFIX/"etc" - end - - # The directory where the formula's binaries should be installed. - # This is symlinked into `HOMEBREW_PREFIX` after installation or with - # `brew link` for formulae that are not keg-only. - sig { returns(Pathname) } - def opt_bin - opt_prefix/"bin" - end - - # The directory where the formula's binaries should be installed. - # This is symlinked into `HOMEBREW_PREFIX` after installation or with - # `brew link` for formulae that are not keg-only. - sig { returns(Pathname) } - def opt_sbin - opt_prefix/"sbin" - end - - # A stable path for this formula, when installed. Contains the formula name - # but no version number. Only the active version will be linked here if - # multiple versions are installed. - sig { returns(Pathname) } - def opt_prefix - HOMEBREW_PREFIX/"opt/#{@formula.name}" - end + delegate [ # rubocop:disable Layout/HashAlignment + :bin, + :var, + :etc, + :opt_bin, + :opt_sbin, + :opt_prefix, + ] => :@formula sig { returns(String) } def std_service_path_env @@ -160,7 +129,9 @@ module Homebrew # @return [String] sig { returns(String) } def to_plist - clean_command = @run.select {|i| i.is_a?(Pathname)} + instance_eval(&@service_block) + + clean_command = @run.select { |i| i.is_a?(Pathname) } .map(&:to_s) base = { diff --git a/Library/Homebrew/test/formula_spec.rb b/Library/Homebrew/test/formula_spec.rb index c1165deb5c..47a8902950 100644 --- a/Library/Homebrew/test/formula_spec.rb +++ b/Library/Homebrew/test/formula_spec.rb @@ -704,22 +704,17 @@ describe Formula do specify "#service" do f = formula do url "https://brew.sh/test-1.0.tbz" - 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 end - expect(f.service.run).to eq([HOMEBREW_PREFIX/"opt/bin/beanstalkd"]) - expect(f.service.error_log_path).to eq("#{HOMEBREW_PREFIX}/var/log/beanstalkd.error.log") - expect(f.service.log_path).to eq("#{HOMEBREW_PREFIX}/var/log/beanstalkd.log") - expect(f.service.working_dir).to eq("#{HOMEBREW_PREFIX}/var") - expect(f.service.keep_alive).to eq(true) - expect(f.service.run_type).to eq("immediate") + 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 @@ -730,7 +725,7 @@ describe Formula do end end - expect(f.service.run).to eq([HOMEBREW_PREFIX/"opt/bin/beanstalkd"]) + expect(f.service).not_to eq(nil) end specify "dependencies" do diff --git a/Library/Homebrew/test/service_spec.rb b/Library/Homebrew/test/service_spec.rb index f6d852814e..aad43d0aea 100644 --- a/Library/Homebrew/test/service_spec.rb +++ b/Library/Homebrew/test/service_spec.rb @@ -15,12 +15,10 @@ describe Homebrew::Service do let(:spec) { :stable } let(:f) { klass.new(name, path, spec) } - let(:service) { described_class.new(f) } - describe "#to_plist" do it "returns valid plist" do - service.instance_eval do - run [opt_bin/"beanstalkd"] + 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" @@ -29,7 +27,7 @@ describe Homebrew::Service do keep_alive true end - plist = service.to_plist + plist = f.service.to_plist expect(plist).to include("Label") expect(plist).to include("homebrew.mxcl.#{name}") expect(plist).to include("KeepAlive") @@ -48,12 +46,12 @@ describe Homebrew::Service do end it "returns valid partial plist" do - service.instance_eval do - run ["#{HOMEBREW_PREFIX}/bin/beanstalkd"] + f.class.service do + run bin/"beanstalkd" run_type :immediate end - plist = service.to_plist + plist = f.service.to_plist expect(plist).to include("homebrew.mxcl.#{name}") expect(plist).to include("Label") expect(plist).not_to include("KeepAlive") From 1ab2726e768a8895dbe144701cc5fa5790e381d4 Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Fri, 9 Apr 2021 13:30:43 +0100 Subject: [PATCH 3/3] Cleanup service plist code. --- Library/Homebrew/formula.rb | 4 ++-- Library/Homebrew/service.rb | 9 +-------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/Library/Homebrew/formula.rb b/Library/Homebrew/formula.rb index 1605605197..b0999bcaa0 100644 --- a/Library/Homebrew/formula.rb +++ b/Library/Homebrew/formula.rb @@ -970,9 +970,9 @@ class Formula # The service specification of the software. def service - return Homebrew::Service.new(self, &self.class.service) if service? + return unless service? - nil + Homebrew::Service.new(self, &self.class.service) end # @private diff --git a/Library/Homebrew/service.rb b/Library/Homebrew/service.rb index fc826c73c6..88f88abaf8 100644 --- a/Library/Homebrew/service.rb +++ b/Library/Homebrew/service.rb @@ -111,14 +111,7 @@ module Homebrew end end - delegate [ # rubocop:disable Layout/HashAlignment - :bin, - :var, - :etc, - :opt_bin, - :opt_sbin, - :opt_prefix, - ] => :@formula + delegate [:bin, :var, :etc, :opt_bin, :opt_sbin, :opt_prefix] => :@formula sig { returns(String) } def std_service_path_env