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.
- checksums.yaml +4 -4
- data/.ruby-version +1 -0
- data/CHANGELOG.md +16 -0
- data/Gemfile.lock +38 -33
- data/README.md +64 -7
- data/lib/wheneverd/cli/activate.rb +5 -5
- data/lib/wheneverd/cli/deactivate.rb +6 -6
- data/lib/wheneverd/cli/reload.rb +8 -8
- data/lib/wheneverd/cli/status.rb +13 -6
- data/lib/wheneverd/cli.rb +10 -8
- data/lib/wheneverd/dsl/context.rb +46 -0
- 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/schedule.rb +15 -1
- data/lib/wheneverd/service.rb +105 -0
- 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 +31 -66
- data/lib/wheneverd/systemd/unit_content_builder.rb +99 -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 -15
- 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 +5 -1
- data/test/cli_activate_test.rb +27 -0
- data/test/cli_reload_test.rb +23 -0
- data/test/cli_status_test.rb +14 -4
- data/test/domain_model_test.rb +105 -0
- data/test/dsl_context_shell_test.rb +31 -0
- data/test/systemd_cron_parser_test.rb +41 -25
- data/test/systemd_renderer_errors_test.rb +1 -1
- data/test/systemd_renderer_test.rb +73 -0
- metadata +16 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
3
|
+
require_relative "period_strategy"
|
|
4
4
|
|
|
5
5
|
module Wheneverd
|
|
6
6
|
module DSL
|
|
@@ -8,24 +8,12 @@ module Wheneverd
|
|
|
8
8
|
#
|
|
9
9
|
# Supported period forms are described in the README.
|
|
10
10
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# -
|
|
14
|
-
#
|
|
15
|
-
# -
|
|
16
|
-
# ({Wheneverd::Trigger::Calendar}).
|
|
17
|
-
# - The `at:` option is only valid for calendar triggers, with a convenience exception for
|
|
18
|
-
# `every 1.day, at: ...` which is treated as a daily calendar trigger.
|
|
11
|
+
# Uses a strategy pattern to delegate parsing to specialized strategy classes:
|
|
12
|
+
# - {PeriodStrategy::DurationStrategy} for Duration values
|
|
13
|
+
# - {PeriodStrategy::StringStrategy} for interval strings and cron expressions
|
|
14
|
+
# - {PeriodStrategy::SymbolStrategy} for calendar symbols
|
|
15
|
+
# - {PeriodStrategy::ArrayStrategy} for arrays of calendar symbols
|
|
19
16
|
class PeriodParser
|
|
20
|
-
DAY_SECONDS = 60 * 60 * 24
|
|
21
|
-
REBOOT_NOT_SUPPORTED_MESSAGE =
|
|
22
|
-
"The :reboot period is not supported; use an interval or calendar period instead"
|
|
23
|
-
|
|
24
|
-
CALENDAR_SYMBOLS = %i[
|
|
25
|
-
hour day month year weekday weekend
|
|
26
|
-
monday tuesday wednesday thursday friday saturday sunday
|
|
27
|
-
].freeze
|
|
28
|
-
|
|
29
17
|
attr_reader :path
|
|
30
18
|
|
|
31
19
|
# @param path [String] schedule path for error reporting
|
|
@@ -38,98 +26,11 @@ module Wheneverd
|
|
|
38
26
|
# @return [Wheneverd::Trigger::Interval, Wheneverd::Trigger::Calendar]
|
|
39
27
|
def trigger_for(period, at:)
|
|
40
28
|
at_times = AtNormalizer.normalize(at, path: path)
|
|
41
|
-
|
|
29
|
+
strategy = PeriodStrategy.for(period, path: path)
|
|
30
|
+
strategy.parse(period, at_times: at_times)
|
|
42
31
|
rescue Wheneverd::InvalidIntervalError => e
|
|
43
32
|
raise InvalidPeriodError.new(e.message, path: path)
|
|
44
33
|
end
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
def trigger_for_period(period, at_times:)
|
|
49
|
-
return duration_trigger_for(period, at_times: at_times) if period.is_a?(Wheneverd::Duration)
|
|
50
|
-
return array_trigger_for(period, at_times: at_times) if period.is_a?(Array)
|
|
51
|
-
return string_trigger_for(period, at_times: at_times) if period.is_a?(String)
|
|
52
|
-
return symbol_trigger_for(period, at_times: at_times) if period.is_a?(Symbol)
|
|
53
|
-
|
|
54
|
-
raise InvalidPeriodError.new("Unsupported period type: #{period.class}", path: path)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def duration_trigger_for(duration, at_times:)
|
|
58
|
-
if at_times.any?
|
|
59
|
-
return daily_calendar_trigger(at_times) if duration.to_i == DAY_SECONDS
|
|
60
|
-
|
|
61
|
-
raise InvalidPeriodError.new("at: is only supported with calendar periods", path: path)
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
Wheneverd::Trigger::Interval.new(seconds: duration.to_i)
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def daily_calendar_trigger(at_times)
|
|
68
|
-
Wheneverd::Trigger::Calendar.new(on_calendar: build_calendar_specs("day", at_times))
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def string_trigger_for(str, at_times:)
|
|
72
|
-
period_str = str.strip
|
|
73
|
-
|
|
74
|
-
return interval_trigger(period_str, at_times: at_times) if interval_string?(period_str)
|
|
75
|
-
|
|
76
|
-
return cron_trigger(period_str, at_times: at_times) if cron_string?(period_str)
|
|
77
|
-
|
|
78
|
-
raise InvalidPeriodError.new("Unrecognized period #{period_str.inspect}", path: path)
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def interval_trigger(period_str, at_times:)
|
|
82
|
-
if at_times.any?
|
|
83
|
-
raise InvalidPeriodError.new("at: is not supported for interval periods", path: path)
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
seconds = Wheneverd::Interval.parse(period_str)
|
|
87
|
-
Wheneverd::Trigger::Interval.new(seconds: seconds)
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def cron_trigger(period_str, at_times:)
|
|
91
|
-
if at_times.any?
|
|
92
|
-
raise InvalidPeriodError.new("at: is not supported for cron periods", path: path)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
Wheneverd::Trigger::Calendar.new(on_calendar: ["cron:#{period_str}"])
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def symbol_trigger_for(sym, at_times:)
|
|
99
|
-
raise InvalidPeriodError.new(REBOOT_NOT_SUPPORTED_MESSAGE, path: path) if sym == :reboot
|
|
100
|
-
|
|
101
|
-
if CALENDAR_SYMBOLS.include?(sym)
|
|
102
|
-
return Wheneverd::Trigger::Calendar.new(
|
|
103
|
-
on_calendar: build_calendar_specs(sym.to_s, at_times)
|
|
104
|
-
)
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
raise InvalidPeriodError.new("Unknown period symbol: #{sym.inspect}", path: path)
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def array_trigger_for(periods, at_times:)
|
|
111
|
-
bases = CalendarSymbolPeriodList.validate(
|
|
112
|
-
periods,
|
|
113
|
-
allowed_symbols: CALENDAR_SYMBOLS,
|
|
114
|
-
path: path
|
|
115
|
-
).map(&:to_s)
|
|
116
|
-
specs = bases.flat_map { |base| build_calendar_specs(base, at_times) }.uniq
|
|
117
|
-
Wheneverd::Trigger::Calendar.new(on_calendar: specs)
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def build_calendar_specs(base, at_times)
|
|
121
|
-
return [base] if at_times.empty?
|
|
122
|
-
|
|
123
|
-
at_times.map { |t| "#{base}@#{t}" }
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def interval_string?(str)
|
|
127
|
-
/\A-?\d+[smhdw]\z/.match?(str)
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def cron_string?(str)
|
|
131
|
-
str.split(/\s+/).length == 5
|
|
132
|
-
end
|
|
133
34
|
end
|
|
134
35
|
end
|
|
135
36
|
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../calendar_symbol_period_list"
|
|
4
|
+
|
|
5
|
+
module Wheneverd
|
|
6
|
+
module DSL
|
|
7
|
+
module PeriodStrategy
|
|
8
|
+
# Strategy for parsing Array period values.
|
|
9
|
+
#
|
|
10
|
+
# Handles arrays of calendar symbols like [:monday, :wednesday, :friday].
|
|
11
|
+
class ArrayStrategy < Base
|
|
12
|
+
def handles?(period)
|
|
13
|
+
period.is_a?(Array)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def parse(periods, at_times:)
|
|
17
|
+
bases = CalendarSymbolPeriodList.validate(
|
|
18
|
+
periods,
|
|
19
|
+
allowed_symbols: CALENDAR_SYMBOLS,
|
|
20
|
+
path: path
|
|
21
|
+
).map(&:to_s)
|
|
22
|
+
|
|
23
|
+
specs = bases.flat_map { |base| build_calendar_specs(base, at_times) }.uniq
|
|
24
|
+
calendar_trigger(on_calendar: specs)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
module DSL
|
|
5
|
+
module PeriodStrategy
|
|
6
|
+
# Base class for period parsing strategies.
|
|
7
|
+
#
|
|
8
|
+
# Each strategy handles a specific type of period value (Duration, String, Symbol, Array)
|
|
9
|
+
# and converts it into a trigger object.
|
|
10
|
+
class Base
|
|
11
|
+
DAY_SECONDS = 60 * 60 * 24
|
|
12
|
+
|
|
13
|
+
CALENDAR_SYMBOLS = %i[
|
|
14
|
+
hour day month year weekday weekend
|
|
15
|
+
monday tuesday wednesday thursday friday saturday sunday
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :path
|
|
19
|
+
|
|
20
|
+
# @param path [String] schedule path for error reporting
|
|
21
|
+
def initialize(path:)
|
|
22
|
+
@path = path
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Check if this strategy handles the given period type.
|
|
26
|
+
#
|
|
27
|
+
# @param period [Object] the period value
|
|
28
|
+
# @return [Boolean]
|
|
29
|
+
def handles?(_period)
|
|
30
|
+
raise NotImplementedError, "#{self.class} must implement #handles?"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Parse the period into a trigger.
|
|
34
|
+
#
|
|
35
|
+
# @param period [Object] the period value
|
|
36
|
+
# @param at_times [Array<String>] normalized at: times
|
|
37
|
+
# @return [Wheneverd::Trigger::Interval, Wheneverd::Trigger::Calendar]
|
|
38
|
+
# @raise [Wheneverd::DSL::InvalidPeriodError]
|
|
39
|
+
def parse(_period, at_times:)
|
|
40
|
+
raise NotImplementedError, "#{self.class} must implement #parse"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
protected
|
|
44
|
+
|
|
45
|
+
def raise_period_error(message)
|
|
46
|
+
raise InvalidPeriodError.new(message, path: path)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build_calendar_specs(base, at_times)
|
|
50
|
+
return [base] if at_times.empty?
|
|
51
|
+
|
|
52
|
+
at_times.map { |t| "#{base}@#{t}" }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def calendar_trigger(on_calendar:)
|
|
56
|
+
Wheneverd::Trigger::Calendar.new(on_calendar: on_calendar)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def interval_trigger(seconds:)
|
|
60
|
+
Wheneverd::Trigger::Interval.new(seconds: seconds)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
module DSL
|
|
5
|
+
module PeriodStrategy
|
|
6
|
+
# Strategy for parsing Duration period values.
|
|
7
|
+
#
|
|
8
|
+
# Duration values produce Interval triggers, except for 1.day with at: times
|
|
9
|
+
# which produces a Calendar trigger for daily scheduling.
|
|
10
|
+
class DurationStrategy < Base
|
|
11
|
+
def handles?(period)
|
|
12
|
+
period.is_a?(Wheneverd::Duration)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def parse(duration, at_times:)
|
|
16
|
+
if at_times.any?
|
|
17
|
+
return daily_calendar_trigger(at_times) if duration.to_i == DAY_SECONDS
|
|
18
|
+
|
|
19
|
+
raise_period_error("at: is only supported with calendar periods")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
interval_trigger(seconds: duration.to_i)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def daily_calendar_trigger(at_times)
|
|
28
|
+
calendar_trigger(on_calendar: build_calendar_specs("day", at_times))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
module DSL
|
|
5
|
+
module PeriodStrategy
|
|
6
|
+
# Strategy for parsing String period values.
|
|
7
|
+
#
|
|
8
|
+
# Handles interval strings (e.g., "5m", "1h") and cron expressions.
|
|
9
|
+
class StringStrategy < Base
|
|
10
|
+
INTERVAL_PATTERN = /\A-?\d+[smhdw]\z/.freeze
|
|
11
|
+
|
|
12
|
+
def handles?(period)
|
|
13
|
+
period.is_a?(String)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def parse(str, at_times:)
|
|
17
|
+
period_str = str.strip
|
|
18
|
+
|
|
19
|
+
return parse_interval(period_str, at_times: at_times) if interval_string?(period_str)
|
|
20
|
+
|
|
21
|
+
return parse_cron(period_str, at_times: at_times) if cron_string?(period_str)
|
|
22
|
+
|
|
23
|
+
raise_period_error("Unrecognized period #{period_str.inspect}")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def parse_interval(period_str, at_times:)
|
|
29
|
+
raise_period_error("at: is not supported for interval periods") if at_times.any?
|
|
30
|
+
|
|
31
|
+
seconds = Wheneverd::Interval.parse(period_str)
|
|
32
|
+
interval_trigger(seconds: seconds)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def parse_cron(period_str, at_times:)
|
|
36
|
+
raise_period_error("at: is not supported for cron periods") if at_times.any?
|
|
37
|
+
|
|
38
|
+
calendar_trigger(on_calendar: ["cron:#{period_str}"])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def interval_string?(str)
|
|
42
|
+
INTERVAL_PATTERN.match?(str)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def cron_string?(str)
|
|
46
|
+
str.split(/\s+/).length == 5
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
module DSL
|
|
5
|
+
module PeriodStrategy
|
|
6
|
+
# Strategy for parsing Symbol period values.
|
|
7
|
+
#
|
|
8
|
+
# Handles calendar symbols like :day, :monday, :weekend, etc.
|
|
9
|
+
class SymbolStrategy < Base
|
|
10
|
+
REBOOT_NOT_SUPPORTED_MESSAGE =
|
|
11
|
+
"The :reboot period is not supported; use an interval or calendar period instead"
|
|
12
|
+
|
|
13
|
+
def handles?(period)
|
|
14
|
+
period.is_a?(Symbol)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def parse(sym, at_times:)
|
|
18
|
+
raise_period_error(REBOOT_NOT_SUPPORTED_MESSAGE) if sym == :reboot
|
|
19
|
+
|
|
20
|
+
if CALENDAR_SYMBOLS.include?(sym)
|
|
21
|
+
return calendar_trigger(
|
|
22
|
+
on_calendar: build_calendar_specs(sym.to_s, at_times)
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
raise_period_error("Unknown period symbol: #{sym.inspect}")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "period_strategy/base"
|
|
4
|
+
require_relative "period_strategy/duration_strategy"
|
|
5
|
+
require_relative "period_strategy/string_strategy"
|
|
6
|
+
require_relative "period_strategy/symbol_strategy"
|
|
7
|
+
require_relative "period_strategy/array_strategy"
|
|
8
|
+
|
|
9
|
+
module Wheneverd
|
|
10
|
+
module DSL
|
|
11
|
+
# Period parsing strategies for converting DSL period values into triggers.
|
|
12
|
+
#
|
|
13
|
+
# Each strategy handles a specific type of period value and converts it
|
|
14
|
+
# into the appropriate trigger type.
|
|
15
|
+
module PeriodStrategy
|
|
16
|
+
# Default strategies in order of precedence.
|
|
17
|
+
DEFAULT_STRATEGIES = [
|
|
18
|
+
DurationStrategy,
|
|
19
|
+
ArrayStrategy,
|
|
20
|
+
StringStrategy,
|
|
21
|
+
SymbolStrategy
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
# Find the strategy that handles the given period type.
|
|
25
|
+
#
|
|
26
|
+
# @param period [Object] the period value
|
|
27
|
+
# @param path [String] schedule path for error reporting
|
|
28
|
+
# @return [Base] the strategy instance
|
|
29
|
+
# @raise [Wheneverd::DSL::InvalidPeriodError] if no strategy handles the period
|
|
30
|
+
def self.for(period, path:)
|
|
31
|
+
strategy_class = DEFAULT_STRATEGIES.find do |klass|
|
|
32
|
+
klass.new(path: path).handles?(period)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if strategy_class.nil?
|
|
36
|
+
raise InvalidPeriodError.new("Unsupported period type: #{period.class}", path: path)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
strategy_class.new(path: path)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/wheneverd/duration.rb
CHANGED
|
@@ -11,13 +11,7 @@ module Wheneverd
|
|
|
11
11
|
|
|
12
12
|
# @param seconds [Integer] duration in seconds (must be positive)
|
|
13
13
|
def initialize(seconds)
|
|
14
|
-
|
|
15
|
-
raise ArgumentError, "Duration seconds must be an Integer (got #{seconds.class})"
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
raise ArgumentError, "Duration seconds must be positive (got #{seconds})" if seconds <= 0
|
|
19
|
-
|
|
20
|
-
@seconds = seconds
|
|
14
|
+
@seconds = Validation.positive_integer(seconds, name: "Duration seconds")
|
|
21
15
|
end
|
|
22
16
|
|
|
23
17
|
def to_i
|
data/lib/wheneverd/errors.rb
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Wheneverd
|
|
4
|
+
# Base error class for wheneverd exceptions.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
4
7
|
# Raised when {Wheneverd::Interval.parse} cannot parse or validate an interval string.
|
|
5
8
|
class InvalidIntervalError < Error; end
|
|
6
9
|
|
data/lib/wheneverd/interval.rb
CHANGED
|
@@ -21,17 +21,32 @@ module Wheneverd
|
|
|
21
21
|
# @return [Integer] seconds
|
|
22
22
|
# @raise [Wheneverd::InvalidIntervalError] if the input is invalid
|
|
23
23
|
def self.parse(str)
|
|
24
|
-
input = str
|
|
24
|
+
input = normalize_input(str)
|
|
25
|
+
match = parse_match(input)
|
|
26
|
+
n = parse_number(match[:n], input)
|
|
27
|
+
n * MULTIPLIERS.fetch(match[:unit])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.normalize_input(str)
|
|
31
|
+
str.to_s.strip
|
|
32
|
+
end
|
|
33
|
+
private_class_method :normalize_input
|
|
34
|
+
|
|
35
|
+
def self.parse_match(input)
|
|
25
36
|
match = FORMAT.match(input)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
37
|
+
return match if match
|
|
38
|
+
|
|
39
|
+
raise InvalidIntervalError,
|
|
40
|
+
"Invalid interval #{input.inspect}; expected <n>s|m|h|d|w (example: \"5m\")"
|
|
41
|
+
end
|
|
42
|
+
private_class_method :parse_match
|
|
30
43
|
|
|
31
|
-
|
|
44
|
+
def self.parse_number(number_str, input)
|
|
45
|
+
n = Integer(number_str, 10)
|
|
32
46
|
raise InvalidIntervalError, "Interval must be positive (got #{input.inspect})" if n <= 0
|
|
33
47
|
|
|
34
|
-
n
|
|
48
|
+
n
|
|
35
49
|
end
|
|
50
|
+
private_class_method :parse_number
|
|
36
51
|
end
|
|
37
52
|
end
|
data/lib/wheneverd/schedule.rb
CHANGED
|
@@ -8,9 +8,14 @@ module Wheneverd
|
|
|
8
8
|
# @return [Array<Entry>]
|
|
9
9
|
attr_reader :entries
|
|
10
10
|
|
|
11
|
+
# @return [Array<Wheneverd::Service>]
|
|
12
|
+
attr_reader :services
|
|
13
|
+
|
|
11
14
|
# @param entries [Array<Entry>]
|
|
12
|
-
|
|
15
|
+
# @param services [Array<Wheneverd::Service>]
|
|
16
|
+
def initialize(entries: [], services: [])
|
|
13
17
|
@entries = entries.dup
|
|
18
|
+
@services = services.dup
|
|
14
19
|
end
|
|
15
20
|
|
|
16
21
|
# Append an entry to the schedule.
|
|
@@ -21,5 +26,14 @@ module Wheneverd
|
|
|
21
26
|
entries << entry
|
|
22
27
|
self
|
|
23
28
|
end
|
|
29
|
+
|
|
30
|
+
# Append a long-running service to the schedule.
|
|
31
|
+
#
|
|
32
|
+
# @param service [Wheneverd::Service]
|
|
33
|
+
# @return [Schedule] self
|
|
34
|
+
def add_service(service)
|
|
35
|
+
services << service
|
|
36
|
+
self
|
|
37
|
+
end
|
|
24
38
|
end
|
|
25
39
|
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
# A long-running systemd user service managed alongside scheduled timers.
|
|
5
|
+
class Service
|
|
6
|
+
SAFE_SETTING_NAME = /\A[A-Za-z][A-Za-z0-9]*\z/.freeze
|
|
7
|
+
|
|
8
|
+
# @return [String]
|
|
9
|
+
attr_reader :name
|
|
10
|
+
|
|
11
|
+
# @return [Wheneverd::Job::Command]
|
|
12
|
+
attr_reader :command
|
|
13
|
+
|
|
14
|
+
# @return [String]
|
|
15
|
+
attr_reader :restart
|
|
16
|
+
|
|
17
|
+
# @return [String]
|
|
18
|
+
attr_reader :restart_sec
|
|
19
|
+
|
|
20
|
+
# @return [Array<String>]
|
|
21
|
+
attr_reader :service_lines
|
|
22
|
+
|
|
23
|
+
# @param name [String] stable service name within the schedule
|
|
24
|
+
# @param command [String, Array<String>] command to run as ExecStart
|
|
25
|
+
# @param restart [String] systemd Restart= value
|
|
26
|
+
# @param restart_sec [String] systemd RestartSec= value
|
|
27
|
+
# @param service [Hash, Array<String>] extra [Service] lines
|
|
28
|
+
def initialize(name:, command:, restart: "always", restart_sec: "5s", service: {})
|
|
29
|
+
@name = normalize_name(name)
|
|
30
|
+
@command = Wheneverd::Job::Command.new(command: command)
|
|
31
|
+
@restart = normalize_required_value(restart, "restart")
|
|
32
|
+
@restart_sec = normalize_required_value(restart_sec, "restart_sec")
|
|
33
|
+
@service_lines = normalize_service_lines(service)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Stable signature used for unit naming.
|
|
37
|
+
#
|
|
38
|
+
# @return [String]
|
|
39
|
+
def signature
|
|
40
|
+
[
|
|
41
|
+
"service:#{name}",
|
|
42
|
+
command.signature,
|
|
43
|
+
"restart:#{restart}",
|
|
44
|
+
"restart_sec:#{restart_sec}",
|
|
45
|
+
*service_lines
|
|
46
|
+
].join("\n")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def normalize_name(value)
|
|
52
|
+
str = value.to_s.strip
|
|
53
|
+
raise InvalidCommandError, "Service name must not be empty" if str.empty?
|
|
54
|
+
|
|
55
|
+
str
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def normalize_required_value(value, field)
|
|
59
|
+
str = value.to_s.strip
|
|
60
|
+
raise InvalidCommandError, "Service #{field} must not be empty" if str.empty?
|
|
61
|
+
|
|
62
|
+
str
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def normalize_service_lines(value)
|
|
66
|
+
case value
|
|
67
|
+
when Hash
|
|
68
|
+
value.map { |key, setting| normalize_service_setting(key, setting) }
|
|
69
|
+
when Array
|
|
70
|
+
value.map { |line| normalize_service_line(line) }
|
|
71
|
+
else
|
|
72
|
+
raise InvalidCommandError, "Service extra settings must be a Hash or Array"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def normalize_service_setting(key, value)
|
|
77
|
+
setting_name = key.to_s.strip
|
|
78
|
+
unless SAFE_SETTING_NAME.match?(setting_name)
|
|
79
|
+
raise InvalidCommandError, "Invalid service setting name: #{setting_name.inspect}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
"#{setting_name}=#{normalize_service_setting_value(value)}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def normalize_service_setting_value(value)
|
|
86
|
+
str = value.to_s.strip
|
|
87
|
+
raise InvalidCommandError, "Service setting values must not be empty" if str.empty?
|
|
88
|
+
if str.match?(/[\0\r\n]/)
|
|
89
|
+
raise InvalidCommandError, "Service setting values must not include NUL or newlines"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
str
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def normalize_service_line(line)
|
|
96
|
+
str = line.to_s.strip
|
|
97
|
+
raise InvalidCommandError, "Service lines must not be empty" if str.empty?
|
|
98
|
+
if str.match?(/[\0\r\n]/) || !str.include?("=")
|
|
99
|
+
raise InvalidCommandError, "Service lines must be single KEY=VALUE lines"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
str
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|