service.rb 11.2 KB
Newer Older
1
2
3
4
5
6
7
8
9
# 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
Sean Molenaar's avatar
Sean Molenaar committed
10
    extend Forwardable
11

Sean Molenaar's avatar
Sean Molenaar committed
12
13
14
    RUN_TYPE_IMMEDIATE = :immediate
    RUN_TYPE_INTERVAL = :interval
    RUN_TYPE_CRON = :cron
15

Sean Molenaar's avatar
Sean Molenaar committed
16
17
18
19
    PROCESS_TYPE_BACKGROUND = :background
    PROCESS_TYPE_STANDARD = :standard
    PROCESS_TYPE_INTERACTIVE = :interactive
    PROCESS_TYPE_ADAPTIVE = :adaptive
valrus's avatar
valrus committed
20

21
    # sig { params(formula: Formula).void }
Sean Molenaar's avatar
Sean Molenaar committed
22
    def initialize(formula, &block)
23
24
25
      @formula = formula
      @run_type = RUN_TYPE_IMMEDIATE
      @environment_variables = {}
Sean Molenaar's avatar
Sean Molenaar committed
26
      @service_block = block
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
    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

55
56
57
58
59
60
61
62
    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
63
        raise TypeError, "Service#root_dir expects a String or Pathname"
64
65
66
67
68
69
70
71
72
73
74
      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
75
        raise TypeError, "Service#input_path expects a String or Pathname"
76
77
78
      end
    end

79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
    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

115
116
117
118
119
120
121
122
123
124
125
126
    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

Sean Molenaar's avatar
Sean Molenaar committed
127
128
129
    sig { params(value: T.nilable(Symbol)).returns(T.nilable(Symbol)) }
    def process_type(value = nil)
      case T.unsafe(value)
valrus's avatar
valrus committed
130
131
      when nil
        @process_type
Sean Molenaar's avatar
Sean Molenaar committed
132
133
134
      when :background, :standard, :interactive, :adaptive
        @process_type = value
      when Symbol
valrus's avatar
valrus committed
135
136
137
138
        raise TypeError, "Service#process_type allows: "\
                         "'#{PROCESS_TYPE_BACKGROUND}'/'#{PROCESS_TYPE_STANDARD}'/"\
                         "'#{PROCESS_TYPE_INTERACTIVE}'/'#{PROCESS_TYPE_ADAPTIVE}'"
      else
Sean Molenaar's avatar
Sean Molenaar committed
139
        raise TypeError, "Service#process_type expects a Symbol"
valrus's avatar
valrus committed
140
141
142
      end
    end

Sean Molenaar's avatar
Sean Molenaar committed
143
144
145
    sig { params(value: T.nilable(Symbol)).returns(T.nilable(Symbol)) }
    def run_type(value = nil)
      case T.unsafe(value)
146
147
      when nil
        @run_type
148
      when :immediate, :interval, :cron
Sean Molenaar's avatar
Sean Molenaar committed
149
150
        @run_type = value
      when Symbol
151
152
        raise TypeError, "Service#run_type allows: '#{RUN_TYPE_IMMEDIATE}'/'#{RUN_TYPE_INTERVAL}'/'#{RUN_TYPE_CRON}'"
      else
Sean Molenaar's avatar
Sean Molenaar committed
153
154
155
156
157
158
159
160
161
162
163
164
165
        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"
166
167
168
      end
    end

169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
    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

227
    sig { params(variables: T::Hash[String, String]).returns(T.nilable(T::Hash[String, String])) }
228
229
230
    def environment_variables(variables = {})
      case T.unsafe(variables)
      when Hash
231
        @environment_variables = variables.transform_values(&:to_s)
232
233
234
235
236
      else
        raise TypeError, "Service#environment_variables expects a hash"
      end
    end

237
238
239
240
241
242
243
244
245
246
247
248
    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

249
    delegate [:bin, :etc, :libexec, :opt_bin, :opt_libexec, :opt_pkgshare, :opt_prefix, :opt_sbin, :var] => :@formula
250
251
252
253
254
255

    sig { returns(String) }
    def std_service_path_env
      "#{HOMEBREW_PREFIX}/bin:#{HOMEBREW_PREFIX}/sbin:/usr/bin:/bin:/usr/sbin:/sbin"
    end

256
257
258
    sig { returns(T::Array[String]) }
    def command
      instance_eval(&@service_block)
259
      @run.map(&:to_s)
260
261
    end

262
263
    # Returns the `String` command to run manually instead of the service.
    # @return [String]
264
265
266
267
268
269
270
271
272
273
    sig { returns(String) }
    def manual_command
      instance_eval(&@service_block)
      vars = @environment_variables.except(:PATH)
                                   .map { |k, v| "#{k}=\"#{v}\"" }

      out = vars + command
      out.join(" ")
    end

274
275
276
277
278
279
280
281
    # Returns a `Boolean` describing if a service is timed.
    # @return [Boolean]
    sig { returns(T::Boolean) }
    def timed?
      instance_eval(&@service_block)
      @run_type == RUN_TYPE_CRON || @run_type == RUN_TYPE_INTERVAL
    end

282
283
284
285
    # Returns a `String` plist.
    # @return [String]
    sig { returns(String) }
    def to_plist
Sean Molenaar's avatar
Sean Molenaar committed
286
      # command needs to be first because it initializes all other values
287
288
      base = {
        Label:            @formula.plist_name,
289
        ProgramArguments: command,
Sean Molenaar's avatar
Sean Molenaar committed
290
        RunAtLoad:        @run_type == RUN_TYPE_IMMEDIATE,
291
292
293
      }

      base[:KeepAlive] = @keep_alive if @keep_alive == true
294
295
      base[:LegacyTimers] = @macos_legacy_timers if @macos_legacy_timers == true
      base[:TimeOut] = @restart_delay if @restart_delay.present?
Sean Molenaar's avatar
Sean Molenaar committed
296
297
      base[:ProcessType] = @process_type.to_s.capitalize if @process_type.present?
      base[:StartInterval] = @interval if @interval.present? && @run_type == RUN_TYPE_INTERVAL
298
      base[:WorkingDirectory] = @working_dir if @working_dir.present?
299
300
      base[:RootDirectory] = @root_dir if @root_dir.present?
      base[:StandardInPath] = @input_path if @input_path.present?
301
302
303
304
      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?

305
306
307
308
      if @cron.present? && @run_type == RUN_TYPE_CRON
        base[:StartCalendarInterval] = @cron.reject { |_, value| value == "*" }
      end

309
310
      base.to_plist
    end
311
312
313
314
315
316
317
318
319

    # Returns a `String` systemd unit.
    # @return [String]
    sig { returns(String) }
    def to_systemd_unit
      unit = <<~EOS
        [Unit]
        Description=Homebrew generated unit for #{@formula.name}

320
321
322
        [Install]
        WantedBy=multi-user.target

323
324
        [Service]
        Type=simple
325
        ExecStart=#{command.join(" ")}
326
327
328
329
      EOS

      options = []
      options << "Restart=always" if @keep_alive == true
330
      options << "RestartSec=#{restart_delay}" if @restart_delay.present?
331
      options << "WorkingDirectory=#{@working_dir}" if @working_dir.present?
332
333
      options << "RootDirectory=#{@root_dir}" if @root_dir.present?
      options << "StandardInput=file:#{@input_path}" if @input_path.present?
334
335
      options << "StandardOutput=append:#{@log_path}" if @log_path.present?
      options << "StandardError=append:#{@error_log_path}" if @error_log_path.present?
336
      options += @environment_variables.map { |k, v| "Environment=\"#{k}=#{v}\"" } if @environment_variables.present?
337
338
339

      unit + options.join("\n")
    end
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357

    # 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

      instance_eval(&@service_block)
      options = []
358
      options << "Persistent=true" if @run_type == RUN_TYPE_CRON
359
360
      options << "OnUnitActiveSec=#{@interval}" if @run_type == RUN_TYPE_INTERVAL

361
362
363
364
365
366
      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

367
368
      timer + options.join("\n")
    end
369
370
  end
end