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
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
module Systemd
|
|
5
|
+
module CronParser
|
|
6
|
+
# Parses and formats day-of-week cron field expressions.
|
|
7
|
+
#
|
|
8
|
+
# Day-of-week has specialized handling:
|
|
9
|
+
# - Both 0 and 7 represent Sunday
|
|
10
|
+
# - Wrap-around ranges (e.g., Fri-Mon)
|
|
11
|
+
# - Formatting to systemd's Mon..Fri syntax
|
|
12
|
+
module DowParser
|
|
13
|
+
DOW_NAMES = {
|
|
14
|
+
"sun" => 0,
|
|
15
|
+
"mon" => 1,
|
|
16
|
+
"tue" => 2,
|
|
17
|
+
"wed" => 3,
|
|
18
|
+
"thu" => 4,
|
|
19
|
+
"fri" => 5,
|
|
20
|
+
"sat" => 6
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
DOW_SYSTEMD = {
|
|
24
|
+
0 => "Sun",
|
|
25
|
+
1 => "Mon",
|
|
26
|
+
2 => "Tue",
|
|
27
|
+
3 => "Wed",
|
|
28
|
+
4 => "Thu",
|
|
29
|
+
5 => "Fri",
|
|
30
|
+
6 => "Sat"
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# Day order for formatting (Mon-Sun instead of Sun-Sat)
|
|
34
|
+
DOW_ORDER = [1, 2, 3, 4, 5, 6, 0].freeze
|
|
35
|
+
|
|
36
|
+
# Parses a day-of-week field into a set of days.
|
|
37
|
+
#
|
|
38
|
+
# @param dow_str [String] the day-of-week field value
|
|
39
|
+
# @param input [String] full cron expression for error messages
|
|
40
|
+
# @return [Array<Integer>, nil] array of day numbers (0-6), or nil if all days
|
|
41
|
+
# @raise [Wheneverd::Systemd::UnsupportedCronError]
|
|
42
|
+
def self.parse(dow_str, input:)
|
|
43
|
+
raw = dow_str.to_s.strip
|
|
44
|
+
raise_empty_error(input) if raw.empty?
|
|
45
|
+
return nil if raw == "*"
|
|
46
|
+
|
|
47
|
+
present = Array.new(7, false)
|
|
48
|
+
|
|
49
|
+
raw.split(",").map(&:strip).each do |part|
|
|
50
|
+
apply_part(present, part, input: input)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
days = present.each_index.select { |idx| present[idx] }
|
|
54
|
+
return nil if days.length == 7
|
|
55
|
+
|
|
56
|
+
days
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Formats a set of days into a systemd-compatible expression.
|
|
60
|
+
#
|
|
61
|
+
# @param days [Array<Integer>] array of day numbers (0-6)
|
|
62
|
+
# @return [String] systemd expression (e.g., "Mon..Fri", "Mon,Wed,Fri")
|
|
63
|
+
def self.format(days)
|
|
64
|
+
present = Array.new(7, false)
|
|
65
|
+
days.each { |day| present[day] = true }
|
|
66
|
+
|
|
67
|
+
tokens = []
|
|
68
|
+
i = 0
|
|
69
|
+
while i < DOW_ORDER.length
|
|
70
|
+
day = DOW_ORDER.fetch(i)
|
|
71
|
+
unless present.fetch(day)
|
|
72
|
+
i += 1
|
|
73
|
+
next
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
start = day
|
|
77
|
+
j = i
|
|
78
|
+
j += 1 while (j + 1) < DOW_ORDER.length && present.fetch(DOW_ORDER.fetch(j + 1))
|
|
79
|
+
finish = DOW_ORDER.fetch(j)
|
|
80
|
+
|
|
81
|
+
token = format_range(start, finish)
|
|
82
|
+
tokens << token
|
|
83
|
+
i = j + 1
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
tokens.join(",")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.apply_part(present, part, input:)
|
|
90
|
+
raise_invalid_token_error(input) if part.empty?
|
|
91
|
+
|
|
92
|
+
base, step_str = part.split("/", 2)
|
|
93
|
+
step = parse_step(step_str, input: input)
|
|
94
|
+
|
|
95
|
+
sequence = parse_sequence(base, step, input: input)
|
|
96
|
+
|
|
97
|
+
if step
|
|
98
|
+
sequence.each_with_index { |day, idx| present[day] = true if (idx % step).zero? }
|
|
99
|
+
else
|
|
100
|
+
sequence.each { |day| present[day] = true }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
private_class_method :apply_part
|
|
104
|
+
|
|
105
|
+
def self.parse_sequence(base, step, input:)
|
|
106
|
+
if base == "*"
|
|
107
|
+
(0..6).to_a
|
|
108
|
+
elsif (match = /\A(?<start>[^-]+)-(?<finish>[^-]+)\z/.match(base))
|
|
109
|
+
range_sequence(match[:start], match[:finish], input: input)
|
|
110
|
+
else
|
|
111
|
+
start_day = parse_value(base, input: input)
|
|
112
|
+
start_day = 0 if start_day == 7
|
|
113
|
+
step ? (start_day..6).to_a : [start_day]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
private_class_method :parse_sequence
|
|
117
|
+
|
|
118
|
+
def self.range_sequence(start_token, finish_token, input:)
|
|
119
|
+
start_raw = parse_value(start_token, input: input)
|
|
120
|
+
finish_raw = parse_value(finish_token, input: input)
|
|
121
|
+
|
|
122
|
+
start_day = start_raw == 7 ? 0 : start_raw
|
|
123
|
+
finish_day = finish_raw == 7 ? 0 : finish_raw
|
|
124
|
+
|
|
125
|
+
return (0..6).to_a if start_raw.zero? && finish_raw == 7
|
|
126
|
+
|
|
127
|
+
return (start_day..6).to_a + [0] if finish_raw == 7 && !start_raw.zero?
|
|
128
|
+
|
|
129
|
+
return (start_day..finish_day).to_a if start_day <= finish_day
|
|
130
|
+
|
|
131
|
+
(start_day..6).to_a + (0..finish_day).to_a
|
|
132
|
+
end
|
|
133
|
+
private_class_method :range_sequence
|
|
134
|
+
|
|
135
|
+
def self.parse_value(token, input:)
|
|
136
|
+
raw = token.to_s.strip
|
|
137
|
+
raise_invalid_token_error(input) if raw.empty?
|
|
138
|
+
|
|
139
|
+
return parse_numeric_value(raw, input: input) if /\A\d+\z/.match?(raw)
|
|
140
|
+
|
|
141
|
+
parse_named_value(raw, input: input)
|
|
142
|
+
end
|
|
143
|
+
private_class_method :parse_value
|
|
144
|
+
|
|
145
|
+
def self.parse_numeric_value(raw, input:)
|
|
146
|
+
value = Integer(raw, 10)
|
|
147
|
+
return value if value.between?(0, 7)
|
|
148
|
+
|
|
149
|
+
raise UnsupportedCronError, "Unsupported cron #{input.inspect}: day-of-week out of range"
|
|
150
|
+
end
|
|
151
|
+
private_class_method :parse_numeric_value
|
|
152
|
+
|
|
153
|
+
def self.parse_named_value(raw, input:)
|
|
154
|
+
unless /\A[A-Za-z]+\z/.match?(raw)
|
|
155
|
+
raise UnsupportedCronError,
|
|
156
|
+
"Unsupported cron #{input.inspect}: invalid day-of-week token"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
key = raw.downcase
|
|
160
|
+
key = key[0, 3] if key.length > 3
|
|
161
|
+
return DOW_NAMES.fetch(key) if DOW_NAMES.key?(key)
|
|
162
|
+
|
|
163
|
+
raise UnsupportedCronError, "Unsupported cron #{input.inspect}: invalid day-of-week token"
|
|
164
|
+
end
|
|
165
|
+
private_class_method :parse_named_value
|
|
166
|
+
|
|
167
|
+
def self.parse_step(step_str, input:)
|
|
168
|
+
return nil if step_str.nil?
|
|
169
|
+
|
|
170
|
+
unless /\A\d+\z/.match?(step_str)
|
|
171
|
+
raise UnsupportedCronError,
|
|
172
|
+
"Unsupported cron #{input.inspect}: day-of-week step must be a number"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
value = Integer(step_str, 10)
|
|
176
|
+
unless value.positive?
|
|
177
|
+
raise UnsupportedCronError,
|
|
178
|
+
"Unsupported cron #{input.inspect}: day-of-week step must be positive"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
value
|
|
182
|
+
end
|
|
183
|
+
private_class_method :parse_step
|
|
184
|
+
|
|
185
|
+
def self.format_range(start, finish)
|
|
186
|
+
if start == finish
|
|
187
|
+
DOW_SYSTEMD.fetch(start)
|
|
188
|
+
else
|
|
189
|
+
"#{DOW_SYSTEMD.fetch(start)}..#{DOW_SYSTEMD.fetch(finish)}"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
private_class_method :format_range
|
|
193
|
+
|
|
194
|
+
def self.raise_empty_error(input)
|
|
195
|
+
raise UnsupportedCronError,
|
|
196
|
+
"Unsupported cron #{input.inspect}: day-of-week is empty"
|
|
197
|
+
end
|
|
198
|
+
private_class_method :raise_empty_error
|
|
199
|
+
|
|
200
|
+
def self.raise_invalid_token_error(input)
|
|
201
|
+
raise UnsupportedCronError,
|
|
202
|
+
"Unsupported cron #{input.inspect}: invalid day-of-week token"
|
|
203
|
+
end
|
|
204
|
+
private_class_method :raise_invalid_token_error
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
module Systemd
|
|
5
|
+
module CronParser
|
|
6
|
+
# Parses individual cron field expressions (minute, hour, day-of-month, month).
|
|
7
|
+
#
|
|
8
|
+
# Handles:
|
|
9
|
+
# - Wildcards: `*`
|
|
10
|
+
# - Lists: `1,2,3`
|
|
11
|
+
# - Ranges: `1-5`
|
|
12
|
+
# - Steps: `*/2`, `1-10/2`
|
|
13
|
+
# - Named values (for month): `jan`, `feb`, etc.
|
|
14
|
+
module FieldParser
|
|
15
|
+
# Parses a numeric cron field expression.
|
|
16
|
+
#
|
|
17
|
+
# @param str [String] the field value
|
|
18
|
+
# @param range [Range] valid range for values
|
|
19
|
+
# @param field [String] field name for error messages
|
|
20
|
+
# @param input [String] full cron expression for error messages
|
|
21
|
+
# @param pad [Integer] zero-padding width (0 = no padding)
|
|
22
|
+
# @return [String] systemd-compatible expression
|
|
23
|
+
# @raise [Wheneverd::Systemd::UnsupportedCronError]
|
|
24
|
+
def self.parse_numeric(str, range, field:, input:, pad:)
|
|
25
|
+
expr = parse_mapped(str, range, field: field, input: input, names: {})
|
|
26
|
+
return expr if pad <= 0 || expr == "*"
|
|
27
|
+
|
|
28
|
+
pad_expression_numbers(expr, pad: pad)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Parses a cron field expression with optional name mappings.
|
|
32
|
+
#
|
|
33
|
+
# @param str [String] the field value
|
|
34
|
+
# @param range [Range] valid range for values
|
|
35
|
+
# @param field [String] field name for error messages
|
|
36
|
+
# @param input [String] full cron expression for error messages
|
|
37
|
+
# @param names [Hash<String, Integer>] name-to-value mappings (e.g., {"jan" => 1})
|
|
38
|
+
# @return [String] systemd-compatible expression
|
|
39
|
+
# @raise [Wheneverd::Systemd::UnsupportedCronError]
|
|
40
|
+
def self.parse_mapped(str, range, field:, input:, names:)
|
|
41
|
+
raw = str.to_s.strip
|
|
42
|
+
raise_empty_field_error(field, input) if raw.empty?
|
|
43
|
+
|
|
44
|
+
parts = raw.split(",").map(&:strip)
|
|
45
|
+
return "*" if parts.any? { |part| part == "*" }
|
|
46
|
+
|
|
47
|
+
parts.map do |part|
|
|
48
|
+
part_to_systemd(part, range, field: field, input: input, names: names)
|
|
49
|
+
end.join(",")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.part_to_systemd(part, range, field:, input:, names:)
|
|
53
|
+
base, step_str = part.split("/", 2)
|
|
54
|
+
step = parse_step(step_str, field: field, input: input)
|
|
55
|
+
|
|
56
|
+
base_expr = parse_base_expression(base, range, field: field, input: input, names: names)
|
|
57
|
+
|
|
58
|
+
return base_expr if step.nil?
|
|
59
|
+
|
|
60
|
+
"#{base_expr}/#{step}"
|
|
61
|
+
end
|
|
62
|
+
private_class_method :part_to_systemd
|
|
63
|
+
|
|
64
|
+
def self.parse_base_expression(base, range, field:, input:, names:)
|
|
65
|
+
if base == "*"
|
|
66
|
+
range.begin.to_s
|
|
67
|
+
elsif (match = /\A(?<start>[^-]+)-(?<finish>[^-]+)\z/.match(base))
|
|
68
|
+
parse_range_expression(match[:start], match[:finish], range, field: field, input: input,
|
|
69
|
+
names: names)
|
|
70
|
+
else
|
|
71
|
+
parse_value(base, range, field: field, input: input, names: names).to_s
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
private_class_method :parse_base_expression
|
|
75
|
+
|
|
76
|
+
def self.parse_range_expression(start_token, finish_token, range, field:, input:, names:)
|
|
77
|
+
start_value = parse_value(start_token, range, field: field, input: input, names: names)
|
|
78
|
+
finish_value = parse_value(finish_token, range, field: field, input: input, names: names)
|
|
79
|
+
|
|
80
|
+
unless start_value <= finish_value
|
|
81
|
+
raise UnsupportedCronError,
|
|
82
|
+
"Unsupported cron #{input.inspect}: invalid #{field} range"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
"#{start_value}..#{finish_value}"
|
|
86
|
+
end
|
|
87
|
+
private_class_method :parse_range_expression
|
|
88
|
+
|
|
89
|
+
def self.parse_value(token, range, field:, input:, names:)
|
|
90
|
+
raw = token.to_s.strip
|
|
91
|
+
raise_empty_token_error(field, input) if raw.empty?
|
|
92
|
+
|
|
93
|
+
if /\A\d+\z/.match?(raw)
|
|
94
|
+
return parse_numeric_value(raw, range, field: field,
|
|
95
|
+
input: input)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
parse_named_value(raw, range, field: field, input: input, names: names)
|
|
99
|
+
end
|
|
100
|
+
private_class_method :parse_value
|
|
101
|
+
|
|
102
|
+
def self.parse_numeric_value(raw, range, field:, input:)
|
|
103
|
+
value = Integer(raw, 10)
|
|
104
|
+
return value if range.cover?(value)
|
|
105
|
+
|
|
106
|
+
raise UnsupportedCronError, "Unsupported cron #{input.inspect}: #{field} out of range"
|
|
107
|
+
end
|
|
108
|
+
private_class_method :parse_numeric_value
|
|
109
|
+
|
|
110
|
+
def self.parse_named_value(raw, range, field:, input:, names:)
|
|
111
|
+
key = raw.downcase
|
|
112
|
+
if names.key?(key)
|
|
113
|
+
value = names.fetch(key)
|
|
114
|
+
return value if range.cover?(value)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
raise UnsupportedCronError, "Unsupported cron #{input.inspect}: invalid #{field} token"
|
|
118
|
+
end
|
|
119
|
+
private_class_method :parse_named_value
|
|
120
|
+
|
|
121
|
+
def self.parse_step(step_str, field:, input:)
|
|
122
|
+
return nil if step_str.nil?
|
|
123
|
+
|
|
124
|
+
parse_positive_int(step_str, field: field, input: input, label: "step")
|
|
125
|
+
end
|
|
126
|
+
private_class_method :parse_step
|
|
127
|
+
|
|
128
|
+
def self.parse_positive_int(str, field:, input:, label:)
|
|
129
|
+
unless /\A\d+\z/.match?(str)
|
|
130
|
+
raise UnsupportedCronError,
|
|
131
|
+
"Unsupported cron #{input.inspect}: #{field} #{label} must be a number"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
value = Integer(str, 10)
|
|
135
|
+
unless value.positive?
|
|
136
|
+
raise UnsupportedCronError,
|
|
137
|
+
"Unsupported cron #{input.inspect}: #{field} #{label} must be positive"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
value
|
|
141
|
+
end
|
|
142
|
+
private_class_method :parse_positive_int
|
|
143
|
+
|
|
144
|
+
def self.pad_expression_numbers(expr, pad:)
|
|
145
|
+
expr.gsub(%r{(?<![/\d])\d+}) { |m| m.rjust(pad, "0") }
|
|
146
|
+
end
|
|
147
|
+
private_class_method :pad_expression_numbers
|
|
148
|
+
|
|
149
|
+
def self.raise_empty_field_error(field, input)
|
|
150
|
+
raise UnsupportedCronError,
|
|
151
|
+
"Unsupported cron #{input.inspect}: #{field} is empty"
|
|
152
|
+
end
|
|
153
|
+
private_class_method :raise_empty_field_error
|
|
154
|
+
|
|
155
|
+
def self.raise_empty_token_error(field, input)
|
|
156
|
+
raise UnsupportedCronError,
|
|
157
|
+
"Unsupported cron #{input.inspect}: empty #{field} token"
|
|
158
|
+
end
|
|
159
|
+
private_class_method :raise_empty_token_error
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|