wheneverd 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +16 -0
  4. data/Gemfile.lock +38 -33
  5. data/README.md +64 -7
  6. data/lib/wheneverd/cli/activate.rb +5 -5
  7. data/lib/wheneverd/cli/deactivate.rb +6 -6
  8. data/lib/wheneverd/cli/reload.rb +8 -8
  9. data/lib/wheneverd/cli/status.rb +13 -6
  10. data/lib/wheneverd/cli.rb +10 -8
  11. data/lib/wheneverd/dsl/context.rb +46 -0
  12. data/lib/wheneverd/dsl/period_parser.rb +8 -107
  13. data/lib/wheneverd/dsl/period_strategy/array_strategy.rb +29 -0
  14. data/lib/wheneverd/dsl/period_strategy/base.rb +65 -0
  15. data/lib/wheneverd/dsl/period_strategy/duration_strategy.rb +33 -0
  16. data/lib/wheneverd/dsl/period_strategy/string_strategy.rb +51 -0
  17. data/lib/wheneverd/dsl/period_strategy/symbol_strategy.rb +31 -0
  18. data/lib/wheneverd/dsl/period_strategy.rb +43 -0
  19. data/lib/wheneverd/duration.rb +1 -7
  20. data/lib/wheneverd/errors.rb +3 -0
  21. data/lib/wheneverd/interval.rb +22 -7
  22. data/lib/wheneverd/schedule.rb +15 -1
  23. data/lib/wheneverd/service.rb +105 -0
  24. data/lib/wheneverd/systemd/cron_parser/dow_parser.rb +208 -0
  25. data/lib/wheneverd/systemd/cron_parser/field_parser.rb +163 -0
  26. data/lib/wheneverd/systemd/cron_parser.rb +56 -303
  27. data/lib/wheneverd/systemd/renderer.rb +31 -66
  28. data/lib/wheneverd/systemd/unit_content_builder.rb +99 -0
  29. data/lib/wheneverd/systemd/unit_deleter.rb +2 -28
  30. data/lib/wheneverd/systemd/unit_lister.rb +2 -28
  31. data/lib/wheneverd/systemd/unit_namer.rb +6 -15
  32. data/lib/wheneverd/systemd/unit_path_utils.rb +54 -0
  33. data/lib/wheneverd/systemd/unit_writer.rb +2 -28
  34. data/lib/wheneverd/trigger/base.rb +22 -0
  35. data/lib/wheneverd/trigger/boot.rb +8 -6
  36. data/lib/wheneverd/trigger/calendar.rb +7 -0
  37. data/lib/wheneverd/trigger/interval.rb +8 -6
  38. data/lib/wheneverd/validation.rb +89 -0
  39. data/lib/wheneverd/version.rb +1 -1
  40. data/lib/wheneverd.rb +5 -1
  41. data/test/cli_activate_test.rb +27 -0
  42. data/test/cli_reload_test.rb +23 -0
  43. data/test/cli_status_test.rb +14 -4
  44. data/test/domain_model_test.rb +105 -0
  45. data/test/dsl_context_shell_test.rb +31 -0
  46. data/test/systemd_cron_parser_test.rb +41 -25
  47. data/test/systemd_renderer_errors_test.rb +1 -1
  48. data/test/systemd_renderer_test.rb +73 -0
  49. metadata +16 -2
@@ -1,42 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "cron_parser/field_parser"
4
+ require_relative "cron_parser/dow_parser"
5
+
3
6
  module Wheneverd
4
7
  module Systemd
5
8
  # Converts 5-field cron expressions into systemd `OnCalendar=` specs.
9
+ #
10
+ # Uses {FieldParser} for parsing numeric fields (minute, hour, day-of-month, month)
11
+ # and {DowParser} for day-of-week parsing and formatting.
6
12
  module CronParser
13
+ MONTH_NAMES = {
14
+ "jan" => 1,
15
+ "feb" => 2,
16
+ "mar" => 3,
17
+ "apr" => 4,
18
+ "may" => 5,
19
+ "jun" => 6,
20
+ "jul" => 7,
21
+ "aug" => 8,
22
+ "sep" => 9,
23
+ "oct" => 10,
24
+ "nov" => 11,
25
+ "dec" => 12
26
+ }.freeze
27
+
7
28
  # @param cron_5_fields [String]
8
29
  # @return [Array<String>] systemd `OnCalendar=` values
9
30
  # @raise [Wheneverd::Systemd::UnsupportedCronError]
10
31
  def self.to_on_calendar_values(cron_5_fields)
11
32
  input = cron_5_fields.to_s.strip
12
- minute_str, hour_str, dom_str, month_str, dow_str = split_fields(input)
13
-
14
- minute = parse_numeric_expression(minute_str, 0..59, field: "minute", input: input, pad: 2)
15
- hour = parse_numeric_expression(hour_str, 0..23, field: "hour", input: input, pad: 2)
16
- dom = parse_numeric_expression(dom_str, 1..31, field: "day-of-month", input: input, pad: 0)
17
- month = parse_month_expression(month_str, input: input)
18
- dow_set = parse_dow_set(dow_str, input: input)
19
-
20
- time = "#{hour}:#{minute}:00"
21
- dom_any = dom == "*"
22
- month_any = month == "*"
23
-
24
- date = "*-#{month_any ? '*' : month}-#{dom_any ? '*' : dom}"
25
-
26
- if dow_set.nil? && dom_any
27
- return ["*-#{month_any ? '*' : month}-* #{time}"]
28
- end
29
-
30
- values = []
31
-
32
- if dow_set
33
- dow = format_dow_set(dow_set)
34
- values << "#{dow} *-#{month_any ? '*' : month}-* #{time}"
35
- end
33
+ fields = split_fields(input)
36
34
 
37
- values << "#{date} #{time}" unless dom_any
38
-
39
- values
35
+ parsed = parse_all_fields(fields, input)
36
+ format_on_calendar_values(parsed)
40
37
  end
41
38
 
42
39
  # @param cron_5_fields [String]
@@ -52,301 +49,57 @@ module Wheneverd
52
49
  raise UnsupportedCronError, message
53
50
  end
54
51
 
52
+ ParsedFields = Struct.new(:minute, :hour, :dom, :month, :dow_set, keyword_init: true)
53
+
55
54
  def self.split_fields(input)
56
55
  parts = input.split(/\s+/)
57
- validate_parts_length(parts, input: input)
56
+ unless parts.length == 5
57
+ raise UnsupportedCronError, "Unsupported cron #{input.inspect}: expected 5 fields"
58
+ end
58
59
 
59
60
  parts
60
61
  end
61
62
  private_class_method :split_fields
62
63
 
63
- def self.validate_parts_length(parts, input:)
64
- return if parts.length == 5
65
-
66
- raise UnsupportedCronError, "Unsupported cron #{input.inspect}: expected 5 fields"
67
- end
68
- private_class_method :validate_parts_length
69
-
70
- MONTH_NAMES = {
71
- "jan" => 1,
72
- "feb" => 2,
73
- "mar" => 3,
74
- "apr" => 4,
75
- "may" => 5,
76
- "jun" => 6,
77
- "jul" => 7,
78
- "aug" => 8,
79
- "sep" => 9,
80
- "oct" => 10,
81
- "nov" => 11,
82
- "dec" => 12
83
- }.freeze
84
-
85
- DOW_NAMES = {
86
- "sun" => 0,
87
- "mon" => 1,
88
- "tue" => 2,
89
- "wed" => 3,
90
- "thu" => 4,
91
- "fri" => 5,
92
- "sat" => 6
93
- }.freeze
94
-
95
- DOW_SYSTEMD = {
96
- 0 => "Sun",
97
- 1 => "Mon",
98
- 2 => "Tue",
99
- 3 => "Wed",
100
- 4 => "Thu",
101
- 5 => "Fri",
102
- 6 => "Sat"
103
- }.freeze
104
-
105
- DOW_ORDER = [1, 2, 3, 4, 5, 6, 0].freeze
106
-
107
- def self.parse_month_expression(month_str, input:)
108
- parse_mapped_numeric_expression(
109
- month_str,
110
- 1..12,
111
- field: "month",
112
- input: input,
113
- names: MONTH_NAMES
64
+ def self.parse_all_fields(fields, input)
65
+ minute_str, hour_str, dom_str, month_str, dow_str = fields
66
+
67
+ ParsedFields.new(
68
+ minute: FieldParser.parse_numeric(minute_str, 0..59, field: "minute", input: input,
69
+ pad: 2),
70
+ hour: FieldParser.parse_numeric(hour_str, 0..23, field: "hour", input: input, pad: 2),
71
+ dom: FieldParser.parse_numeric(dom_str, 1..31, field: "day-of-month", input: input,
72
+ pad: 0),
73
+ month: FieldParser.parse_mapped(month_str, 1..12, field: "month", input: input,
74
+ names: MONTH_NAMES),
75
+ dow_set: DowParser.parse(dow_str, input: input)
114
76
  )
115
77
  end
116
- private_class_method :parse_month_expression
117
-
118
- def self.parse_numeric_expression(str, range, field:, input:, pad:)
119
- expr = parse_mapped_numeric_expression(str, range, field: field, input: input, names: {})
120
- return expr if pad <= 0 || expr == "*"
121
-
122
- pad_expression_numbers(expr, pad: pad)
123
- end
124
- private_class_method :parse_numeric_expression
125
-
126
- def self.parse_mapped_numeric_expression(str, range, field:, input:, names:)
127
- raw = str.to_s.strip
128
- if raw.empty?
129
- raise UnsupportedCronError,
130
- "Unsupported cron #{input.inspect}: #{field} is empty"
131
- end
132
-
133
- parts = raw.split(",").map(&:strip)
134
- return "*" if parts.any? { |part| part == "*" }
135
-
136
- parts.map do |part|
137
- mapped_part_to_systemd(part, range, field: field, input: input, names: names)
138
- end.join(",")
139
- end
140
- private_class_method :parse_mapped_numeric_expression
141
-
142
- def self.mapped_part_to_systemd(part, range, field:, input:, names:)
143
- base, step_str = part.split("/", 2)
144
- step = if step_str.nil?
145
- nil
146
- else
147
- parse_positive_int(step_str, field: field, input: input,
148
- label: "step")
149
- end
150
-
151
- base_expr =
152
- if base == "*"
153
- range.begin.to_s
154
- elsif (match = /\A(?<start>[^-]+)-(?<finish>[^-]+)\z/.match(base))
155
- start_value = parse_mapped_value(match[:start], range, field: field, input: input,
156
- names: names)
157
- finish_value = parse_mapped_value(match[:finish], range, field: field, input: input,
158
- names: names)
159
- unless start_value <= finish_value
160
- raise UnsupportedCronError,
161
- "Unsupported cron #{input.inspect}: invalid #{field} range"
162
- end
163
-
164
- "#{start_value}..#{finish_value}"
165
- else
166
- parse_mapped_value(base, range, field: field, input: input, names: names).to_s
167
- end
168
-
169
- return base_expr if step.nil?
170
-
171
- "#{base_expr}/#{step}"
172
- end
173
- private_class_method :mapped_part_to_systemd
174
-
175
- def self.parse_mapped_value(token, range, field:, input:, names:)
176
- raw = token.to_s.strip
177
- if raw.empty?
178
- raise UnsupportedCronError,
179
- "Unsupported cron #{input.inspect}: empty #{field} token"
180
- end
181
-
182
- if /\A\d+\z/.match?(raw)
183
- value = Integer(raw, 10)
184
- unless range.cover?(value)
185
- raise UnsupportedCronError, "Unsupported cron #{input.inspect}: #{field} out of range"
186
- end
187
-
188
- return value
189
- end
190
-
191
- key = raw.downcase
192
- if names.key?(key)
193
- value = names.fetch(key)
194
- return value if range.cover?(value)
195
- end
196
-
197
- raise UnsupportedCronError, "Unsupported cron #{input.inspect}: invalid #{field} token"
198
- end
199
- private_class_method :parse_mapped_value
200
-
201
- def self.pad_expression_numbers(expr, pad:)
202
- expr.gsub(%r{(?<![/\d])\d+}) { |m| m.rjust(pad, "0") }
203
- end
204
- private_class_method :pad_expression_numbers
205
-
206
- def self.parse_positive_int(str, field:, input:, label:)
207
- unless /\A\d+\z/.match?(str)
208
- raise UnsupportedCronError,
209
- "Unsupported cron #{input.inspect}: #{field} #{label} must be a number"
210
- end
211
-
212
- value = Integer(str, 10)
213
- unless value.positive?
214
- raise UnsupportedCronError,
215
- "Unsupported cron #{input.inspect}: #{field} #{label} must be positive"
216
- end
217
-
218
- value
219
- end
220
- private_class_method :parse_positive_int
221
-
222
- def self.parse_dow_set(dow_str, input:)
223
- raw = dow_str.to_s.strip
224
- if raw.empty?
225
- raise UnsupportedCronError,
226
- "Unsupported cron #{input.inspect}: day-of-week is empty"
227
- end
228
- return nil if raw == "*"
229
-
230
- present = Array.new(7, false)
231
-
232
- raw.split(",").map(&:strip).each do |part|
233
- apply_dow_part(present, part, input: input)
234
- end
235
-
236
- days = present.each_index.select { |idx| present[idx] }
237
- return nil if days.length == 7
238
-
239
- days
240
- end
241
- private_class_method :parse_dow_set
242
-
243
- def self.apply_dow_part(present, part, input:)
244
- if part.empty?
245
- raise UnsupportedCronError,
246
- "Unsupported cron #{input.inspect}: invalid day-of-week token"
247
- end
78
+ private_class_method :parse_all_fields
248
79
 
249
- base, step_str = part.split("/", 2)
250
- step = if step_str.nil?
251
- nil
252
- else
253
- parse_positive_int(step_str, field: "day-of-week",
254
- input: input, label: "step")
255
- end
80
+ def self.format_on_calendar_values(parsed)
81
+ time = "#{parsed.hour}:#{parsed.minute}:00"
82
+ dom_any = parsed.dom == "*"
83
+ month_any = parsed.month == "*"
256
84
 
257
- sequence =
258
- if base == "*"
259
- (0..6).to_a
260
- elsif (match = /\A(?<start>[^-]+)-(?<finish>[^-]+)\z/.match(base))
261
- dow_range_sequence(match[:start], match[:finish], input: input)
262
- else
263
- start_day = parse_dow_value(base, input: input)
264
- start_day = 0 if start_day == 7
265
- step ? (start_day..6).to_a : [start_day]
266
- end
85
+ date = "*-#{month_any ? '*' : parsed.month}-#{dom_any ? '*' : parsed.dom}"
267
86
 
268
- if step
269
- sequence.each_with_index { |day, idx| present[day] = true if (idx % step).zero? }
270
- else
271
- sequence.each { |day| present[day] = true }
87
+ if parsed.dow_set.nil? && dom_any
88
+ return ["*-#{month_any ? '*' : parsed.month}-* #{time}"]
272
89
  end
273
- end
274
- private_class_method :apply_dow_part
275
-
276
- def self.dow_range_sequence(start_token, finish_token, input:)
277
- start_raw = parse_dow_value(start_token, input: input)
278
- finish_raw = parse_dow_value(finish_token, input: input)
279
-
280
- start_day = start_raw == 7 ? 0 : start_raw
281
- finish_day = finish_raw == 7 ? 0 : finish_raw
282
-
283
- return (0..6).to_a if start_raw.zero? && finish_raw == 7
284
-
285
- return (start_day..6).to_a + [0] if finish_raw == 7 && !start_raw.zero?
286
-
287
- return (start_day..finish_day).to_a if start_day <= finish_day
288
-
289
- (start_day..6).to_a + (0..finish_day).to_a
290
- end
291
- private_class_method :dow_range_sequence
292
90
 
293
- def self.parse_dow_value(token, input:)
294
- raw = token.to_s.strip
295
- if raw.empty?
296
- raise UnsupportedCronError,
297
- "Unsupported cron #{input.inspect}: invalid day-of-week token"
298
- end
299
-
300
- if /\A\d+\z/.match?(raw)
301
- value = Integer(raw, 10)
302
- return value if value.between?(0, 7)
303
-
304
- raise UnsupportedCronError, "Unsupported cron #{input.inspect}: day-of-week out of range"
305
- end
91
+ values = []
306
92
 
307
- unless /\A[A-Za-z]+\z/.match?(raw)
308
- raise UnsupportedCronError, "Unsupported cron #{input.inspect}: invalid day-of-week token"
93
+ if parsed.dow_set
94
+ dow = DowParser.format(parsed.dow_set)
95
+ values << "#{dow} *-#{month_any ? '*' : parsed.month}-* #{time}"
309
96
  end
310
97
 
311
- key = raw.downcase
312
- key = key[0, 3] if key.length > 3
313
- return DOW_NAMES.fetch(key) if DOW_NAMES.key?(key)
314
-
315
- raise UnsupportedCronError, "Unsupported cron #{input.inspect}: invalid day-of-week token"
316
- end
317
- private_class_method :parse_dow_value
318
-
319
- def self.format_dow_set(days)
320
- present = Array.new(7, false)
321
- days.each { |day| present[day] = true }
322
-
323
- tokens = []
324
- i = 0
325
- while i < DOW_ORDER.length
326
- day = DOW_ORDER.fetch(i)
327
- unless present.fetch(day)
328
- i += 1
329
- next
330
- end
331
-
332
- start = day
333
- j = i
334
- j += 1 while (j + 1) < DOW_ORDER.length && present.fetch(DOW_ORDER.fetch(j + 1))
335
- finish = DOW_ORDER.fetch(j)
336
-
337
- token =
338
- if start == finish
339
- DOW_SYSTEMD.fetch(start)
340
- else
341
- "#{DOW_SYSTEMD.fetch(start)}..#{DOW_SYSTEMD.fetch(finish)}"
342
- end
343
- tokens << token
344
- i = j + 1
345
- end
98
+ values << "#{date} #{time}" unless dom_any
346
99
 
347
- tokens.join(",")
100
+ values
348
101
  end
349
- private_class_method :format_dow_set
102
+ private_class_method :format_on_calendar_values
350
103
  end
351
104
  end
352
105
  end
@@ -10,7 +10,7 @@ module Wheneverd
10
10
  # @return [Symbol] `:service` or `:timer`
11
11
  # @!attribute [r] contents
12
12
  # @return [String] unit file contents
13
- Unit = Struct.new(:path_basename, :kind, :contents, keyword_init: true)
13
+ Unit = Struct.new(:path_basename, :kind, :contents, :activation, keyword_init: true)
14
14
 
15
15
  # Renders a {Wheneverd::Schedule} into systemd units.
16
16
  #
@@ -25,10 +25,10 @@ module Wheneverd
25
25
  # `-N` suffix is appended to avoid collisions.
26
26
  #
27
27
  # The identifier is sanitized for use in filenames (invalid characters become `-`).
28
+ #
29
+ # Uses {UnitContentBuilder} for generating unit file contents.
28
30
  class Renderer
29
31
  MARKER_PREFIX = "# Generated by wheneverd (wheneverd)"
30
- SERVICE_SECTION = ["[Service]", "Type=oneshot"].freeze
31
- TIMER_SUFFIX = ["Persistent=true", "", "[Install]", "WantedBy=timers.target", ""].freeze
32
32
 
33
33
  # Render a schedule to `systemd` units.
34
34
  #
@@ -37,7 +37,7 @@ module Wheneverd
37
37
  # @return [Array<Unit>]
38
38
  def self.render(schedule, identifier:)
39
39
  validate_schedule(schedule)
40
- id = sanitize_identifier(identifier)
40
+ id = UnitPathUtils.sanitize_identifier(identifier)
41
41
  render_schedule(schedule, id)
42
42
  end
43
43
 
@@ -52,7 +52,7 @@ module Wheneverd
52
52
  stable_ids = Wheneverd::Systemd::UnitNamer.stable_ids_for(schedule)
53
53
  stable_id_index = 0
54
54
 
55
- schedule.entries.flat_map do |entry|
55
+ units = schedule.entries.flat_map do |entry|
56
56
  entry.jobs.flat_map do |job|
57
57
  stable_id = stable_ids.fetch(stable_id_index)
58
58
  stable_id_index += 1
@@ -60,9 +60,20 @@ module Wheneverd
60
60
  render_job(base, entry.trigger, job)
61
61
  end
62
62
  end
63
+ units.concat(render_services(schedule.services, id))
64
+ units
63
65
  end
64
66
  private_class_method :render_schedule
65
67
 
68
+ def self.render_services(services, id)
69
+ services.map do |service|
70
+ stable_id = Wheneverd::Systemd::UnitNamer.stable_id_for(service.signature)
71
+ base = "wheneverd-#{id}-#{stable_id}"
72
+ build_standalone_service_unit("#{base}.service", service)
73
+ end
74
+ end
75
+ private_class_method :render_services
76
+
66
77
  def self.render_job(base, trigger, job)
67
78
  service = build_service_unit("#{base}.service", job)
68
79
  timer = build_timer_unit("#{base}.timer", trigger)
@@ -74,78 +85,32 @@ module Wheneverd
74
85
  Unit.new(
75
86
  path_basename: path_basename,
76
87
  kind: :service,
77
- contents: service_contents(path_basename, job.command)
88
+ contents: UnitContentBuilder.service_contents(path_basename, job.command),
89
+ activation: :timer_managed
78
90
  )
79
91
  end
80
92
  private_class_method :build_service_unit
81
93
 
94
+ def self.build_standalone_service_unit(path_basename, service)
95
+ Unit.new(
96
+ path_basename: path_basename,
97
+ kind: :service,
98
+ contents: UnitContentBuilder.standalone_service_contents(path_basename, service),
99
+ activation: :service
100
+ )
101
+ end
102
+ private_class_method :build_standalone_service_unit
103
+
82
104
  def self.build_timer_unit(path_basename, trigger)
105
+ timer_lines = UnitContentBuilder.timer_lines_for(trigger)
83
106
  Unit.new(
84
107
  path_basename: path_basename,
85
108
  kind: :timer,
86
- contents: timer_contents(path_basename, timer_lines_for(trigger))
109
+ contents: UnitContentBuilder.timer_contents(path_basename, timer_lines),
110
+ activation: :timer
87
111
  )
88
112
  end
89
113
  private_class_method :build_timer_unit
90
-
91
- def self.timer_lines_for(trigger)
92
- case trigger
93
- when Wheneverd::Trigger::Interval
94
- ["OnActiveSec=#{trigger.seconds}", "OnUnitActiveSec=#{trigger.seconds}"]
95
- when Wheneverd::Trigger::Boot
96
- ["OnBootSec=#{trigger.seconds}"]
97
- when Wheneverd::Trigger::Calendar then calendar_timer_lines(trigger)
98
- else raise ArgumentError, "Unsupported trigger type: #{trigger.class}"
99
- end
100
- end
101
- private_class_method :timer_lines_for
102
-
103
- def self.calendar_timer_lines(trigger)
104
- trigger.on_calendar.flat_map do |spec|
105
- CalendarSpec.to_on_calendar_values(spec).map { |value| "OnCalendar=#{value}" }
106
- end
107
- end
108
- private_class_method :calendar_timer_lines
109
-
110
- def self.service_contents(path_basename, command)
111
- ([
112
- marker,
113
- "[Unit]",
114
- "Description=wheneverd job #{path_basename}",
115
- ""
116
- ] + SERVICE_SECTION + ["ExecStart=#{command}", ""]).join("\n")
117
- end
118
- private_class_method :service_contents
119
-
120
- def self.timer_contents(path_basename, timer_lines)
121
- ([
122
- marker,
123
- "[Unit]",
124
- "Description=wheneverd timer #{path_basename}",
125
- "",
126
- "[Timer]"
127
- ] + timer_lines + TIMER_SUFFIX).join("\n")
128
- end
129
- private_class_method :timer_contents
130
-
131
- def self.marker
132
- "#{MARKER_PREFIX} #{Wheneverd::VERSION}; do not edit."
133
- end
134
- private_class_method :marker
135
-
136
- def self.sanitize_identifier(identifier)
137
- raw = identifier.to_s.strip
138
- raise InvalidIdentifierError, "identifier must not be empty" if raw.empty?
139
-
140
- sanitized = raw.gsub(/[^A-Za-z0-9_-]/, "-").gsub(/-+/, "-").gsub(/\A-|-+\z/, "")
141
- if sanitized.empty?
142
- raise InvalidIdentifierError,
143
- "identifier must include at least one alphanumeric character"
144
- end
145
-
146
- sanitized
147
- end
148
- private_class_method :sanitize_identifier
149
114
  end
150
115
  end
151
116
  end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module Systemd
5
+ # Builds the text content for systemd unit files.
6
+ #
7
+ # Handles the structure of service and timer unit files, including
8
+ # the marker header, sections, and proper formatting.
9
+ module UnitContentBuilder
10
+ SERVICE_SECTION = ["[Service]", "Type=oneshot"].freeze
11
+ TIMER_SUFFIX = ["Persistent=true", "", "[Install]", "WantedBy=timers.target", ""].freeze
12
+
13
+ # Build service unit file contents.
14
+ #
15
+ # @param path_basename [String] unit file name for description
16
+ # @param command [String] ExecStart command
17
+ # @return [String] complete unit file contents
18
+ def self.service_contents(path_basename, command)
19
+ build_unit(
20
+ description: "wheneverd job #{path_basename}",
21
+ sections: SERVICE_SECTION + ["ExecStart=#{command}", ""]
22
+ )
23
+ end
24
+
25
+ # Build a long-running service unit file.
26
+ #
27
+ # @param path_basename [String] unit file name for description
28
+ # @param service [Wheneverd::Service]
29
+ # @return [String] complete unit file contents
30
+ def self.standalone_service_contents(path_basename, service)
31
+ build_unit(
32
+ description: "wheneverd service #{path_basename}",
33
+ sections: [
34
+ "[Service]",
35
+ "Type=simple",
36
+ "ExecStart=#{service.command.command}",
37
+ "Restart=#{service.restart}",
38
+ "RestartSec=#{service.restart_sec}",
39
+ *service.service_lines,
40
+ "",
41
+ "[Install]",
42
+ "WantedBy=default.target",
43
+ ""
44
+ ]
45
+ )
46
+ end
47
+
48
+ # Build timer unit file contents.
49
+ #
50
+ # @param path_basename [String] unit file name for description
51
+ # @param timer_lines [Array<String>] timer configuration lines (OnCalendar, OnActiveSec, etc.)
52
+ # @return [String] complete unit file contents
53
+ def self.timer_contents(path_basename, timer_lines)
54
+ build_unit(
55
+ description: "wheneverd timer #{path_basename}",
56
+ sections: ["[Timer]"] + timer_lines + TIMER_SUFFIX
57
+ )
58
+ end
59
+
60
+ # Build timer lines for a trigger.
61
+ #
62
+ # @param trigger [Wheneverd::Trigger::Base] the trigger
63
+ # @return [Array<String>] timer configuration lines
64
+ # @raise [ArgumentError] if trigger type is unsupported
65
+ def self.timer_lines_for(trigger)
66
+ # Calendar triggers need special handling to convert DSL specs to systemd specs
67
+ return calendar_timer_lines(trigger) if trigger.is_a?(Wheneverd::Trigger::Calendar)
68
+
69
+ unless trigger.respond_to?(:systemd_timer_lines)
70
+ raise ArgumentError, "Unsupported trigger type: #{trigger.class}"
71
+ end
72
+
73
+ trigger.systemd_timer_lines
74
+ end
75
+
76
+ def self.calendar_timer_lines(trigger)
77
+ trigger.on_calendar.flat_map do |spec|
78
+ CalendarSpec.to_on_calendar_values(spec).map { |value| "OnCalendar=#{value}" }
79
+ end
80
+ end
81
+ private_class_method :calendar_timer_lines
82
+
83
+ def self.build_unit(description:, sections:)
84
+ ([
85
+ marker,
86
+ "[Unit]",
87
+ "Description=#{description}",
88
+ ""
89
+ ] + sections).join("\n")
90
+ end
91
+ private_class_method :build_unit
92
+
93
+ def self.marker
94
+ "#{Renderer::MARKER_PREFIX} #{Wheneverd::VERSION}; do not edit."
95
+ end
96
+ private_class_method :marker
97
+ end
98
+ end
99
+ end