wheneverd 0.1.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 +7 -0
- data/.gitignore +26 -0
- data/.rubocop.yml +41 -0
- data/.yardopts +8 -0
- data/AGENTS.md +42 -0
- data/CHANGELOG.md +28 -0
- data/FEATURE_SUMMARY.md +38 -0
- data/Gemfile +16 -0
- data/Gemfile.lock +129 -0
- data/LICENSE +21 -0
- data/README.md +204 -0
- data/Rakefile +196 -0
- data/bin/console +8 -0
- data/bin/setup +5 -0
- data/exe/wheneverd +9 -0
- data/lib/wheneverd/cli/activate.rb +19 -0
- data/lib/wheneverd/cli/current.rb +22 -0
- data/lib/wheneverd/cli/deactivate.rb +19 -0
- data/lib/wheneverd/cli/delete.rb +20 -0
- data/lib/wheneverd/cli/help.rb +18 -0
- data/lib/wheneverd/cli/init.rb +78 -0
- data/lib/wheneverd/cli/reload.rb +40 -0
- data/lib/wheneverd/cli/show.rb +23 -0
- data/lib/wheneverd/cli/write.rb +32 -0
- data/lib/wheneverd/cli.rb +87 -0
- data/lib/wheneverd/core_ext/numeric_duration.rb +56 -0
- data/lib/wheneverd/dsl/at_normalizer.rb +48 -0
- data/lib/wheneverd/dsl/calendar_symbol_period_list.rb +42 -0
- data/lib/wheneverd/dsl/context.rb +72 -0
- data/lib/wheneverd/dsl/errors.rb +29 -0
- data/lib/wheneverd/dsl/loader.rb +49 -0
- data/lib/wheneverd/dsl/period_parser.rb +135 -0
- data/lib/wheneverd/duration.rb +27 -0
- data/lib/wheneverd/entry.rb +31 -0
- data/lib/wheneverd/errors.rb +9 -0
- data/lib/wheneverd/interval.rb +37 -0
- data/lib/wheneverd/job/command.rb +29 -0
- data/lib/wheneverd/schedule.rb +25 -0
- data/lib/wheneverd/systemd/calendar_spec.rb +109 -0
- data/lib/wheneverd/systemd/cron_parser.rb +352 -0
- data/lib/wheneverd/systemd/errors.rb +23 -0
- data/lib/wheneverd/systemd/renderer.rb +153 -0
- data/lib/wheneverd/systemd/systemctl.rb +38 -0
- data/lib/wheneverd/systemd/time_parser.rb +75 -0
- data/lib/wheneverd/systemd/unit_deleter.rb +64 -0
- data/lib/wheneverd/systemd/unit_lister.rb +59 -0
- data/lib/wheneverd/systemd/unit_namer.rb +69 -0
- data/lib/wheneverd/systemd/unit_writer.rb +132 -0
- data/lib/wheneverd/trigger/boot.rb +26 -0
- data/lib/wheneverd/trigger/calendar.rb +26 -0
- data/lib/wheneverd/trigger/interval.rb +30 -0
- data/lib/wheneverd/version.rb +6 -0
- data/lib/wheneverd.rb +41 -0
- data/test/cli_activate_test.rb +110 -0
- data/test/cli_current_test.rb +94 -0
- data/test/cli_deactivate_test.rb +111 -0
- data/test/cli_end_to_end_test.rb +98 -0
- data/test/cli_reload_test.rb +132 -0
- data/test/cli_systemctl_integration_test.rb +76 -0
- data/test/cli_systemd_analyze_test.rb +64 -0
- data/test/cli_test.rb +332 -0
- data/test/domain_model_test.rb +108 -0
- data/test/dsl_calendar_symbol_period_list_test.rb +53 -0
- data/test/dsl_loader_test.rb +384 -0
- data/test/support/cli_subprocess_test_helpers.rb +38 -0
- data/test/support/cli_test_helpers.rb +114 -0
- data/test/systemd_calendar_spec_test.rb +45 -0
- data/test/systemd_cron_parser_test.rb +114 -0
- data/test/systemd_renderer_errors_test.rb +85 -0
- data/test/systemd_renderer_test.rb +161 -0
- data/test/systemd_systemctl_test.rb +46 -0
- data/test/systemd_time_parser_test.rb +25 -0
- data/test/systemd_unit_deleter_test.rb +83 -0
- data/test/systemd_unit_writer_prune_test.rb +85 -0
- data/test/systemd_unit_writer_test.rb +71 -0
- data/test/test_helper.rb +34 -0
- data/test/wheneverd_test.rb +9 -0
- data/wheneverd.gemspec +35 -0
- metadata +136 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
module Systemd
|
|
5
|
+
# Converts 5-field cron expressions into systemd `OnCalendar=` specs.
|
|
6
|
+
module CronParser
|
|
7
|
+
# @param cron_5_fields [String]
|
|
8
|
+
# @return [Array<String>] systemd `OnCalendar=` values
|
|
9
|
+
# @raise [Wheneverd::Systemd::UnsupportedCronError]
|
|
10
|
+
def self.to_on_calendar_values(cron_5_fields)
|
|
11
|
+
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
|
|
36
|
+
|
|
37
|
+
values << "#{date} #{time}" unless dom_any
|
|
38
|
+
|
|
39
|
+
values
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param cron_5_fields [String]
|
|
43
|
+
# @return [String] systemd `OnCalendar=` value (only when cron translates to a single value)
|
|
44
|
+
# @raise [Wheneverd::Systemd::UnsupportedCronError]
|
|
45
|
+
def self.to_on_calendar(cron_5_fields)
|
|
46
|
+
values = to_on_calendar_values(cron_5_fields)
|
|
47
|
+
return values.fetch(0) if values.length == 1
|
|
48
|
+
|
|
49
|
+
message =
|
|
50
|
+
"Unsupported cron #{cron_5_fields.to_s.strip.inspect}: " \
|
|
51
|
+
"requires multiple OnCalendar values"
|
|
52
|
+
raise UnsupportedCronError, message
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.split_fields(input)
|
|
56
|
+
parts = input.split(/\s+/)
|
|
57
|
+
validate_parts_length(parts, input: input)
|
|
58
|
+
|
|
59
|
+
parts
|
|
60
|
+
end
|
|
61
|
+
private_class_method :split_fields
|
|
62
|
+
|
|
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
|
|
114
|
+
)
|
|
115
|
+
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
|
|
248
|
+
|
|
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
|
|
256
|
+
|
|
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
|
|
267
|
+
|
|
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 }
|
|
272
|
+
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
|
+
|
|
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
|
|
306
|
+
|
|
307
|
+
unless /\A[A-Za-z]+\z/.match?(raw)
|
|
308
|
+
raise UnsupportedCronError, "Unsupported cron #{input.inspect}: invalid day-of-week token"
|
|
309
|
+
end
|
|
310
|
+
|
|
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
|
|
346
|
+
|
|
347
|
+
tokens.join(",")
|
|
348
|
+
end
|
|
349
|
+
private_class_method :format_dow_set
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
module Systemd
|
|
5
|
+
# Base error class for systemd rendering and system interactions.
|
|
6
|
+
class Error < Wheneverd::Error; end
|
|
7
|
+
|
|
8
|
+
# Raised when an identifier is invalid for use in unit file names.
|
|
9
|
+
class InvalidIdentifierError < Error; end
|
|
10
|
+
|
|
11
|
+
# Raised when a human-friendly time string cannot be parsed.
|
|
12
|
+
class InvalidTimeError < Error; end
|
|
13
|
+
|
|
14
|
+
# Raised when a calendar spec cannot be mapped to a valid `OnCalendar=` value.
|
|
15
|
+
class InvalidCalendarSpecError < Error; end
|
|
16
|
+
|
|
17
|
+
# Raised when the provided cron expression is outside the supported subset.
|
|
18
|
+
class UnsupportedCronError < Error; end
|
|
19
|
+
|
|
20
|
+
# Raised when a `systemctl` invocation fails.
|
|
21
|
+
class SystemctlError < Error; end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
module Systemd
|
|
5
|
+
# A rendered systemd unit (service or timer).
|
|
6
|
+
#
|
|
7
|
+
# @!attribute [r] path_basename
|
|
8
|
+
# @return [String] file name, e.g. `"wheneverd-myapp-0123abcd4567.timer"`
|
|
9
|
+
# @!attribute [r] kind
|
|
10
|
+
# @return [Symbol] `:service` or `:timer`
|
|
11
|
+
# @!attribute [r] contents
|
|
12
|
+
# @return [String] unit file contents
|
|
13
|
+
Unit = Struct.new(:path_basename, :kind, :contents, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
# Renders a {Wheneverd::Schedule} into systemd units.
|
|
16
|
+
#
|
|
17
|
+
# Each job in each entry becomes a `*.service` plus matching `*.timer`.
|
|
18
|
+
#
|
|
19
|
+
# Unit file names are generated as:
|
|
20
|
+
#
|
|
21
|
+
# `wheneverd-<identifier>-<stable_id>.{service,timer}`
|
|
22
|
+
#
|
|
23
|
+
# The stable ID is derived from the job's trigger + command so reordering schedule blocks
|
|
24
|
+
# does not rename units. If there are duplicate jobs with identical trigger + command, a stable
|
|
25
|
+
# `-N` suffix is appended to avoid collisions.
|
|
26
|
+
#
|
|
27
|
+
# The identifier is sanitized for use in filenames (invalid characters become `-`).
|
|
28
|
+
class Renderer
|
|
29
|
+
MARKER_PREFIX = "# Generated by wheneverd (wheneverd)"
|
|
30
|
+
SERVICE_SECTION = ["[Service]", "Type=oneshot"].freeze
|
|
31
|
+
TIMER_SUFFIX = ["Persistent=true", "", "[Install]", "WantedBy=timers.target", ""].freeze
|
|
32
|
+
|
|
33
|
+
# Render a schedule to `systemd` units.
|
|
34
|
+
#
|
|
35
|
+
# @param schedule [Wheneverd::Schedule]
|
|
36
|
+
# @param identifier [String] used to namespace unit file names
|
|
37
|
+
# @return [Array<Unit>]
|
|
38
|
+
def self.render(schedule, identifier:)
|
|
39
|
+
validate_schedule(schedule)
|
|
40
|
+
id = sanitize_identifier(identifier)
|
|
41
|
+
render_schedule(schedule, id)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.validate_schedule(schedule)
|
|
45
|
+
return if schedule.is_a?(Wheneverd::Schedule)
|
|
46
|
+
|
|
47
|
+
raise ArgumentError, "schedule must be a Wheneverd::Schedule (got #{schedule.class})"
|
|
48
|
+
end
|
|
49
|
+
private_class_method :validate_schedule
|
|
50
|
+
|
|
51
|
+
def self.render_schedule(schedule, id)
|
|
52
|
+
stable_ids = Wheneverd::Systemd::UnitNamer.stable_ids_for(schedule)
|
|
53
|
+
stable_id_index = 0
|
|
54
|
+
|
|
55
|
+
schedule.entries.flat_map do |entry|
|
|
56
|
+
entry.jobs.flat_map do |job|
|
|
57
|
+
stable_id = stable_ids.fetch(stable_id_index)
|
|
58
|
+
stable_id_index += 1
|
|
59
|
+
base = "wheneverd-#{id}-#{stable_id}"
|
|
60
|
+
render_job(base, entry.trigger, job)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
private_class_method :render_schedule
|
|
65
|
+
|
|
66
|
+
def self.render_job(base, trigger, job)
|
|
67
|
+
service = build_service_unit("#{base}.service", job)
|
|
68
|
+
timer = build_timer_unit("#{base}.timer", trigger)
|
|
69
|
+
[service, timer]
|
|
70
|
+
end
|
|
71
|
+
private_class_method :render_job
|
|
72
|
+
|
|
73
|
+
def self.build_service_unit(path_basename, job)
|
|
74
|
+
Unit.new(
|
|
75
|
+
path_basename: path_basename,
|
|
76
|
+
kind: :service,
|
|
77
|
+
contents: service_contents(path_basename, job.command)
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
private_class_method :build_service_unit
|
|
81
|
+
|
|
82
|
+
def self.build_timer_unit(path_basename, trigger)
|
|
83
|
+
timer_lines = timer_lines_for(trigger)
|
|
84
|
+
|
|
85
|
+
Unit.new(
|
|
86
|
+
path_basename: path_basename,
|
|
87
|
+
kind: :timer,
|
|
88
|
+
contents: timer_contents(path_basename, timer_lines)
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
private_class_method :build_timer_unit
|
|
92
|
+
|
|
93
|
+
def self.timer_lines_for(trigger)
|
|
94
|
+
case trigger
|
|
95
|
+
when Wheneverd::Trigger::Interval
|
|
96
|
+
["OnActiveSec=#{trigger.seconds}", "OnUnitActiveSec=#{trigger.seconds}"]
|
|
97
|
+
when Wheneverd::Trigger::Boot
|
|
98
|
+
["OnBootSec=#{trigger.seconds}"]
|
|
99
|
+
when Wheneverd::Trigger::Calendar then calendar_timer_lines(trigger)
|
|
100
|
+
else raise ArgumentError, "Unsupported trigger type: #{trigger.class}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
private_class_method :timer_lines_for
|
|
104
|
+
|
|
105
|
+
def self.calendar_timer_lines(trigger)
|
|
106
|
+
trigger.on_calendar.flat_map do |spec|
|
|
107
|
+
CalendarSpec.to_on_calendar_values(spec).map { |value| "OnCalendar=#{value}" }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
private_class_method :calendar_timer_lines
|
|
111
|
+
|
|
112
|
+
def self.service_contents(path_basename, command)
|
|
113
|
+
([
|
|
114
|
+
marker,
|
|
115
|
+
"[Unit]",
|
|
116
|
+
"Description=wheneverd job #{path_basename}",
|
|
117
|
+
""
|
|
118
|
+
] + SERVICE_SECTION + ["ExecStart=#{command}", ""]).join("\n")
|
|
119
|
+
end
|
|
120
|
+
private_class_method :service_contents
|
|
121
|
+
|
|
122
|
+
def self.timer_contents(path_basename, timer_lines)
|
|
123
|
+
([
|
|
124
|
+
marker,
|
|
125
|
+
"[Unit]",
|
|
126
|
+
"Description=wheneverd timer #{path_basename}",
|
|
127
|
+
"",
|
|
128
|
+
"[Timer]"
|
|
129
|
+
] + timer_lines + TIMER_SUFFIX).join("\n")
|
|
130
|
+
end
|
|
131
|
+
private_class_method :timer_contents
|
|
132
|
+
|
|
133
|
+
def self.marker
|
|
134
|
+
"#{MARKER_PREFIX} #{Wheneverd::VERSION}; do not edit."
|
|
135
|
+
end
|
|
136
|
+
private_class_method :marker
|
|
137
|
+
|
|
138
|
+
def self.sanitize_identifier(identifier)
|
|
139
|
+
raw = identifier.to_s.strip
|
|
140
|
+
raise InvalidIdentifierError, "identifier must not be empty" if raw.empty?
|
|
141
|
+
|
|
142
|
+
sanitized = raw.gsub(/[^A-Za-z0-9_-]/, "-").gsub(/-+/, "-").gsub(/\A-|-+\z/, "")
|
|
143
|
+
if sanitized.empty?
|
|
144
|
+
raise InvalidIdentifierError,
|
|
145
|
+
"identifier must include at least one alphanumeric character"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
sanitized
|
|
149
|
+
end
|
|
150
|
+
private_class_method :sanitize_identifier
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Wheneverd
|
|
6
|
+
module Systemd
|
|
7
|
+
# Thin wrapper around `systemctl` that raises on non-zero exit status.
|
|
8
|
+
class Systemctl
|
|
9
|
+
# Run `systemctl` and return stdout/stderr.
|
|
10
|
+
#
|
|
11
|
+
# @param args [Array<String>]
|
|
12
|
+
# @param user [Boolean] use `--user` (default: true)
|
|
13
|
+
# @return [Array(String, String)] stdout and stderr
|
|
14
|
+
# @raise [Wheneverd::Systemd::SystemctlError]
|
|
15
|
+
def self.run(*args, user: true)
|
|
16
|
+
cmd = ["systemctl"]
|
|
17
|
+
cmd << "--user" if user
|
|
18
|
+
cmd << "--no-pager"
|
|
19
|
+
cmd.concat(args.flatten.map(&:to_s))
|
|
20
|
+
|
|
21
|
+
stdout, stderr, status = Open3.capture3(*cmd)
|
|
22
|
+
raise SystemctlError, format_error(cmd, status, stdout, stderr) unless status.success?
|
|
23
|
+
|
|
24
|
+
[stdout, stderr]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.format_error(cmd, status, stdout, stderr)
|
|
28
|
+
details = []
|
|
29
|
+
details << "command: #{cmd.join(' ')}"
|
|
30
|
+
details << "status: #{status.exitstatus}"
|
|
31
|
+
details << "stdout: #{stdout.strip}" unless stdout.to_s.strip.empty?
|
|
32
|
+
details << "stderr: #{stderr.strip}" unless stderr.to_s.strip.empty?
|
|
33
|
+
"systemctl failed (#{details.join(', ')})"
|
|
34
|
+
end
|
|
35
|
+
private_class_method :format_error
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
module Systemd
|
|
5
|
+
# Parses human-friendly times into `HH:MM:SS` for systemd `OnCalendar=` specs.
|
|
6
|
+
module TimeParser
|
|
7
|
+
# @param str [String]
|
|
8
|
+
# @return [String] time in `HH:MM:SS` format
|
|
9
|
+
# @raise [Wheneverd::Systemd::InvalidTimeError]
|
|
10
|
+
def self.parse(str)
|
|
11
|
+
input = str.to_s.strip
|
|
12
|
+
raise InvalidTimeError, "Invalid time: empty" if input.empty?
|
|
13
|
+
|
|
14
|
+
if (match = /\A(?<h>\d{1,2}):(?<m>\d{2})(?::(?<s>\d{2}))?\z/.match(input))
|
|
15
|
+
return parse_24h(match)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
if (match = /\A(?<h>\d{1,2})(?::(?<m>\d{2}))?\s*(?<ampm>am|pm)\z/i.match(input))
|
|
19
|
+
return parse_12h(match)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
raise InvalidTimeError, "Invalid time: #{input.inspect}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.parse_24h(match)
|
|
26
|
+
hour = Integer(match[:h], 10)
|
|
27
|
+
minute = Integer(match[:m], 10)
|
|
28
|
+
second = match[:s] ? Integer(match[:s], 10) : 0
|
|
29
|
+
|
|
30
|
+
validate_parts(hour, minute, second)
|
|
31
|
+
|
|
32
|
+
format("%<hour>02d:%<minute>02d:%<second>02d", hour: hour, minute: minute, second: second)
|
|
33
|
+
end
|
|
34
|
+
private_class_method :parse_24h
|
|
35
|
+
|
|
36
|
+
def self.parse_12h(match)
|
|
37
|
+
hour12 = Integer(match[:h], 10)
|
|
38
|
+
minute = match[:m] ? Integer(match[:m], 10) : 0
|
|
39
|
+
second = 0
|
|
40
|
+
|
|
41
|
+
unless hour12.between?(1, 12)
|
|
42
|
+
raise InvalidTimeError, "Invalid time hour: #{hour12} (expected 1..12)"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
validate_parts(0, minute, second)
|
|
46
|
+
|
|
47
|
+
hour24 = hour24_from_12h(hour12, match[:ampm].downcase)
|
|
48
|
+
|
|
49
|
+
format("%<hour>02d:%<minute>02d:%<second>02d", hour: hour24, minute: minute, second: second)
|
|
50
|
+
end
|
|
51
|
+
private_class_method :parse_12h
|
|
52
|
+
|
|
53
|
+
def self.hour24_from_12h(hour12, ampm)
|
|
54
|
+
hour24 = hour12 % 12
|
|
55
|
+
hour24 += 12 if ampm == "pm"
|
|
56
|
+
hour24
|
|
57
|
+
end
|
|
58
|
+
private_class_method :hour24_from_12h
|
|
59
|
+
|
|
60
|
+
def self.validate_parts(hour, minute, second)
|
|
61
|
+
validate_part("hour", hour, 0..23)
|
|
62
|
+
validate_part("minute", minute, 0..59)
|
|
63
|
+
validate_part("second", second, 0..59)
|
|
64
|
+
end
|
|
65
|
+
private_class_method :validate_parts
|
|
66
|
+
|
|
67
|
+
def self.validate_part(name, value, range)
|
|
68
|
+
return if range.cover?(value)
|
|
69
|
+
|
|
70
|
+
raise InvalidTimeError, "Invalid time #{name}: #{value} (expected #{range})"
|
|
71
|
+
end
|
|
72
|
+
private_class_method :validate_part
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|