diff --git a/Library/Homebrew/service.rb b/Library/Homebrew/service.rb index 1750d3d9a2..205ac4085c 100644 --- a/Library/Homebrew/service.rb +++ b/Library/Homebrew/service.rb @@ -145,10 +145,8 @@ module Homebrew case T.unsafe(value) when nil @run_type - when :immediate, :interval + when :immediate, :interval, :cron @run_type = value - when :cron - raise TypeError, "Service#run_type does not support cron" when Symbol raise TypeError, "Service#run_type allows: '#{RUN_TYPE_IMMEDIATE}'/'#{RUN_TYPE_INTERVAL}'/'#{RUN_TYPE_CRON}'" else @@ -168,6 +166,64 @@ module Homebrew 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[String, String]).returns(T.nilable(T::Hash[String, String])) } def environment_variables(variables = {}) case T.unsafe(variables) @@ -246,6 +302,10 @@ module Homebrew base[:StandardErrorPath] = @error_log_path if @error_log_path.present? base[:EnvironmentVariables] = @environment_variables unless @environment_variables.empty? + if @cron.present? && @run_type == RUN_TYPE_CRON + base[:StartCalendarInterval] = @cron.reject { |_, value| value == "*" } + end + base.to_plist end @@ -295,9 +355,15 @@ module Homebrew instance_eval(&@service_block) options = [] - options << "Persistent=true=" if @run_type == RUN_TYPE_CRON + 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 end diff --git a/Library/Homebrew/test/service_spec.rb b/Library/Homebrew/test/service_spec.rb index 542df63220..da1c8ec20d 100644 --- a/Library/Homebrew/test/service_spec.rb +++ b/Library/Homebrew/test/service_spec.rb @@ -32,16 +32,20 @@ describe Homebrew::Service do end end - describe "#run_type" do - it "throws for cron type" do + describe "#process_type" do + it "throws for unexpected type" do f.class.service do run opt_bin/"beanstalkd" - run_type :cron + process_type :cow end - expect { f.service.manual_command }.to raise_error TypeError, "Service#run_type does not support cron" + expect { + f.service.manual_command + }.to raise_error TypeError, "Service#process_type allows: 'background'/'standard'/'interactive'/'adaptive'" end + end + describe "#run_type" do it "throws for unexpected type" do f.class.service do run opt_bin/"beanstalkd" @@ -206,6 +210,40 @@ describe Homebrew::Service do EOS expect(plist).to eq(plist_expect) end + + it "returns valid cron plist" do + f.class.service do + run opt_bin/"beanstalkd" + run_type :cron + cron "@daily" + end + + plist = f.service.to_plist + plist_expect = <<~EOS + + + + + \tLabel + \thomebrew.mxcl.formula_name + \tProgramArguments + \t + \t\t#{HOMEBREW_PREFIX}/opt/formula_name/bin/beanstalkd + \t + \tRunAtLoad + \t + \tStartCalendarInterval + \t + \t\tHour + \t\t0 + \t\tMinute + \t\t0 + \t + + + EOS + expect(plist).to eq(plist_expect) + end end describe "#to_systemd_unit" do @@ -314,6 +352,53 @@ describe Homebrew::Service do EOS expect(unit).to eq(unit_expect) end + + it "throws on incomplete cron" do + f.class.service do + run opt_bin/"beanstalkd" + run_type :cron + cron "1 2 3 4" + end + + expect { + f.service.to_systemd_timer + }.to raise_error TypeError, "Service#parse_cron expects a valid cron syntax" + end + + it "returns valid cron timers" do + styles = { + "@hourly": "*-*-*-* *:00:00", + "@daily": "*-*-*-* 00:00:00", + "@weekly": "0-*-*-* 00:00:00", + "@monthly": "*-*-*-1 00:00:00", + "@yearly": "*-*-1-1 00:00:00", + "@annually": "*-*-1-1 00:00:00", + "5 5 5 5 5": "5-*-5-5 05:05:00", + } + + styles.each do |cron, calendar| + f.class.service do + run opt_bin/"beanstalkd" + run_type :cron + cron cron.to_s + end + + unit = f.service.to_systemd_timer + unit_expect = <<~EOS + [Unit] + Description=Homebrew generated timer for formula_name + + [Install] + WantedBy=timers.target + + [Timer] + Unit=homebrew.formula_name + Persistent=true + OnCalendar=#{calendar} + EOS + expect(unit).to eq(unit_expect.chomp) + end + end end describe "#timed?" do