wheneverd 0.3.0 → 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +45 -2
- data/lib/wheneverd/dsl/period_parser.rb +8 -107
- data/lib/wheneverd/dsl/period_strategy/array_strategy.rb +29 -0
- data/lib/wheneverd/dsl/period_strategy/base.rb +65 -0
- data/lib/wheneverd/dsl/period_strategy/duration_strategy.rb +33 -0
- data/lib/wheneverd/dsl/period_strategy/string_strategy.rb +51 -0
- data/lib/wheneverd/dsl/period_strategy/symbol_strategy.rb +31 -0
- data/lib/wheneverd/dsl/period_strategy.rb +43 -0
- data/lib/wheneverd/duration.rb +1 -7
- data/lib/wheneverd/errors.rb +3 -0
- data/lib/wheneverd/interval.rb +22 -7
- data/lib/wheneverd/systemd/cron_parser/dow_parser.rb +208 -0
- data/lib/wheneverd/systemd/cron_parser/field_parser.rb +163 -0
- data/lib/wheneverd/systemd/cron_parser.rb +56 -303
- data/lib/wheneverd/systemd/renderer.rb +6 -64
- data/lib/wheneverd/systemd/unit_content_builder.rb +76 -0
- data/lib/wheneverd/systemd/unit_deleter.rb +2 -28
- data/lib/wheneverd/systemd/unit_lister.rb +2 -28
- data/lib/wheneverd/systemd/unit_namer.rb +6 -14
- data/lib/wheneverd/systemd/unit_path_utils.rb +54 -0
- data/lib/wheneverd/systemd/unit_writer.rb +2 -28
- data/lib/wheneverd/trigger/base.rb +22 -0
- data/lib/wheneverd/trigger/boot.rb +8 -6
- data/lib/wheneverd/trigger/calendar.rb +7 -0
- data/lib/wheneverd/trigger/interval.rb +8 -6
- data/lib/wheneverd/validation.rb +89 -0
- data/lib/wheneverd/version.rb +1 -1
- data/lib/wheneverd.rb +4 -1
- data/test/domain_model_test.rb +105 -0
- data/test/systemd_cron_parser_test.rb +41 -25
- data/test/systemd_renderer_errors_test.rb +1 -1
- metadata +13 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 :
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
+
values
|
|
348
101
|
end
|
|
349
|
-
private_class_method :
|
|
102
|
+
private_class_method :format_on_calendar_values
|
|
350
103
|
end
|
|
351
104
|
end
|
|
352
105
|
end
|
|
@@ -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
|
|
|
@@ -74,78 +74,20 @@ module Wheneverd
|
|
|
74
74
|
Unit.new(
|
|
75
75
|
path_basename: path_basename,
|
|
76
76
|
kind: :service,
|
|
77
|
-
contents: service_contents(path_basename, job.command)
|
|
77
|
+
contents: UnitContentBuilder.service_contents(path_basename, job.command)
|
|
78
78
|
)
|
|
79
79
|
end
|
|
80
80
|
private_class_method :build_service_unit
|
|
81
81
|
|
|
82
82
|
def self.build_timer_unit(path_basename, trigger)
|
|
83
|
+
timer_lines = UnitContentBuilder.timer_lines_for(trigger)
|
|
83
84
|
Unit.new(
|
|
84
85
|
path_basename: path_basename,
|
|
85
86
|
kind: :timer,
|
|
86
|
-
contents: timer_contents(path_basename,
|
|
87
|
+
contents: UnitContentBuilder.timer_contents(path_basename, timer_lines)
|
|
87
88
|
)
|
|
88
89
|
end
|
|
89
90
|
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
91
|
end
|
|
150
92
|
end
|
|
151
93
|
end
|
|
@@ -0,0 +1,76 @@
|
|
|
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 timer unit file contents.
|
|
26
|
+
#
|
|
27
|
+
# @param path_basename [String] unit file name for description
|
|
28
|
+
# @param timer_lines [Array<String>] timer configuration lines (OnCalendar, OnActiveSec, etc.)
|
|
29
|
+
# @return [String] complete unit file contents
|
|
30
|
+
def self.timer_contents(path_basename, timer_lines)
|
|
31
|
+
build_unit(
|
|
32
|
+
description: "wheneverd timer #{path_basename}",
|
|
33
|
+
sections: ["[Timer]"] + timer_lines + TIMER_SUFFIX
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Build timer lines for a trigger.
|
|
38
|
+
#
|
|
39
|
+
# @param trigger [Wheneverd::Trigger::Base] the trigger
|
|
40
|
+
# @return [Array<String>] timer configuration lines
|
|
41
|
+
# @raise [ArgumentError] if trigger type is unsupported
|
|
42
|
+
def self.timer_lines_for(trigger)
|
|
43
|
+
# Calendar triggers need special handling to convert DSL specs to systemd specs
|
|
44
|
+
return calendar_timer_lines(trigger) if trigger.is_a?(Wheneverd::Trigger::Calendar)
|
|
45
|
+
|
|
46
|
+
unless trigger.respond_to?(:systemd_timer_lines)
|
|
47
|
+
raise ArgumentError, "Unsupported trigger type: #{trigger.class}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
trigger.systemd_timer_lines
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.calendar_timer_lines(trigger)
|
|
54
|
+
trigger.on_calendar.flat_map do |spec|
|
|
55
|
+
CalendarSpec.to_on_calendar_values(spec).map { |value| "OnCalendar=#{value}" }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
private_class_method :calendar_timer_lines
|
|
59
|
+
|
|
60
|
+
def self.build_unit(description:, sections:)
|
|
61
|
+
([
|
|
62
|
+
marker,
|
|
63
|
+
"[Unit]",
|
|
64
|
+
"Description=#{description}",
|
|
65
|
+
""
|
|
66
|
+
] + sections).join("\n")
|
|
67
|
+
end
|
|
68
|
+
private_class_method :build_unit
|
|
69
|
+
|
|
70
|
+
def self.marker
|
|
71
|
+
"#{Renderer::MARKER_PREFIX} #{Wheneverd::VERSION}; do not edit."
|
|
72
|
+
end
|
|
73
|
+
private_class_method :marker
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -16,7 +16,7 @@ module Wheneverd
|
|
|
16
16
|
dest_dir = File.expand_path(unit_dir.to_s)
|
|
17
17
|
return [] unless Dir.exist?(dest_dir)
|
|
18
18
|
|
|
19
|
-
deleted = deletable_paths(dest_dir, basename_pattern(identifier))
|
|
19
|
+
deleted = deletable_paths(dest_dir, UnitPathUtils.basename_pattern(identifier))
|
|
20
20
|
deleted.each { |path| FileUtils.rm_f(path) unless dry_run }
|
|
21
21
|
deleted
|
|
22
22
|
end
|
|
@@ -27,38 +27,12 @@ module Wheneverd
|
|
|
27
27
|
|
|
28
28
|
path = File.join(dest_dir, basename)
|
|
29
29
|
next unless File.file?(path)
|
|
30
|
-
next unless generated_marker?(path)
|
|
30
|
+
next unless UnitPathUtils.generated_marker?(path)
|
|
31
31
|
|
|
32
32
|
path
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
35
|
private_class_method :deletable_paths
|
|
36
|
-
|
|
37
|
-
def self.basename_pattern(identifier)
|
|
38
|
-
id = sanitize_identifier(identifier)
|
|
39
|
-
/\Awheneverd-#{Regexp.escape(id)}-(?:[0-9a-f]{12}(?:-\d+)?|e\d+-j\d+)\.(service|timer)\z/
|
|
40
|
-
end
|
|
41
|
-
private_class_method :basename_pattern
|
|
42
|
-
|
|
43
|
-
def self.generated_marker?(path)
|
|
44
|
-
first_line = File.open(path, "r") { |f| f.gets.to_s }
|
|
45
|
-
first_line.start_with?(Wheneverd::Systemd::Renderer::MARKER_PREFIX)
|
|
46
|
-
end
|
|
47
|
-
private_class_method :generated_marker?
|
|
48
|
-
|
|
49
|
-
def self.sanitize_identifier(identifier)
|
|
50
|
-
raw = identifier.to_s.strip
|
|
51
|
-
raise InvalidIdentifierError, "identifier must not be empty" if raw.empty?
|
|
52
|
-
|
|
53
|
-
sanitized = raw.gsub(/[^A-Za-z0-9_-]/, "-").gsub(/-+/, "-").gsub(/\A-|-+\z/, "")
|
|
54
|
-
if sanitized.empty?
|
|
55
|
-
raise InvalidIdentifierError,
|
|
56
|
-
"identifier must include at least one alphanumeric character"
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
sanitized
|
|
60
|
-
end
|
|
61
|
-
private_class_method :sanitize_identifier
|
|
62
36
|
end
|
|
63
37
|
end
|
|
64
38
|
end
|
|
@@ -13,7 +13,7 @@ module Wheneverd
|
|
|
13
13
|
dest_dir = File.expand_path(unit_dir.to_s)
|
|
14
14
|
return [] unless Dir.exist?(dest_dir)
|
|
15
15
|
|
|
16
|
-
unit_paths(dest_dir, basename_pattern(identifier))
|
|
16
|
+
unit_paths(dest_dir, UnitPathUtils.basename_pattern(identifier))
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def self.unit_paths(dest_dir, pattern)
|
|
@@ -22,38 +22,12 @@ module Wheneverd
|
|
|
22
22
|
|
|
23
23
|
path = File.join(dest_dir, basename)
|
|
24
24
|
next unless File.file?(path)
|
|
25
|
-
next unless generated_marker?(path)
|
|
25
|
+
next unless UnitPathUtils.generated_marker?(path)
|
|
26
26
|
|
|
27
27
|
path
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
private_class_method :unit_paths
|
|
31
|
-
|
|
32
|
-
def self.basename_pattern(identifier)
|
|
33
|
-
id = sanitize_identifier(identifier)
|
|
34
|
-
/\Awheneverd-#{Regexp.escape(id)}-(?:[0-9a-f]{12}(?:-\d+)?|e\d+-j\d+)\.(service|timer)\z/
|
|
35
|
-
end
|
|
36
|
-
private_class_method :basename_pattern
|
|
37
|
-
|
|
38
|
-
def self.generated_marker?(path)
|
|
39
|
-
first_line = File.open(path, "r") { |f| f.gets.to_s }
|
|
40
|
-
first_line.start_with?(Wheneverd::Systemd::Renderer::MARKER_PREFIX)
|
|
41
|
-
end
|
|
42
|
-
private_class_method :generated_marker?
|
|
43
|
-
|
|
44
|
-
def self.sanitize_identifier(identifier)
|
|
45
|
-
raw = identifier.to_s.strip
|
|
46
|
-
raise InvalidIdentifierError, "identifier must not be empty" if raw.empty?
|
|
47
|
-
|
|
48
|
-
sanitized = raw.gsub(/[^A-Za-z0-9_-]/, "-").gsub(/-+/, "-").gsub(/\A-|-+\z/, "")
|
|
49
|
-
if sanitized.empty?
|
|
50
|
-
raise InvalidIdentifierError,
|
|
51
|
-
"identifier must include at least one alphanumeric character"
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
sanitized
|
|
55
|
-
end
|
|
56
|
-
private_class_method :sanitize_identifier
|
|
57
31
|
end
|
|
58
32
|
end
|
|
59
33
|
end
|