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,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
module DSL
|
|
5
|
+
# Validates `every(:monday, :tuesday, ...)` symbol lists.
|
|
6
|
+
module CalendarSymbolPeriodList
|
|
7
|
+
# @param periods [Array<Symbol>]
|
|
8
|
+
# @param allowed_symbols [Array<Symbol>]
|
|
9
|
+
# @param path [String]
|
|
10
|
+
# @return [Array<Symbol>] the validated input
|
|
11
|
+
def self.validate(periods, allowed_symbols:, path:)
|
|
12
|
+
validate_array!(periods, path: path)
|
|
13
|
+
validate_symbols!(periods, path: path)
|
|
14
|
+
validate_allowed_symbols!(periods, allowed_symbols: allowed_symbols, path: path)
|
|
15
|
+
periods
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.validate_array!(periods, path:)
|
|
19
|
+
return if periods.is_a?(Array) && !periods.empty?
|
|
20
|
+
|
|
21
|
+
raise InvalidPeriodError.new("every() periods must be a non-empty Array", path: path)
|
|
22
|
+
end
|
|
23
|
+
private_class_method :validate_array!
|
|
24
|
+
|
|
25
|
+
def self.validate_symbols!(periods, path:)
|
|
26
|
+
return if periods.all?(Symbol)
|
|
27
|
+
|
|
28
|
+
raise InvalidPeriodError.new("every() periods must be Symbols", path: path)
|
|
29
|
+
end
|
|
30
|
+
private_class_method :validate_symbols!
|
|
31
|
+
|
|
32
|
+
def self.validate_allowed_symbols!(periods, allowed_symbols:, path:)
|
|
33
|
+
invalid = periods.reject { |sym| allowed_symbols.include?(sym) }.uniq
|
|
34
|
+
return if invalid.empty?
|
|
35
|
+
|
|
36
|
+
unknown = invalid.map(&:inspect).join(", ")
|
|
37
|
+
raise InvalidPeriodError.new("Unknown period symbol(s): #{unknown}", path: path)
|
|
38
|
+
end
|
|
39
|
+
private_class_method :validate_allowed_symbols!
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
module DSL
|
|
5
|
+
# The evaluation context used for schedule files.
|
|
6
|
+
#
|
|
7
|
+
# The schedule file is evaluated via `instance_eval`, so methods defined here become available
|
|
8
|
+
# as the schedule DSL (`every`, `command`).
|
|
9
|
+
class Context
|
|
10
|
+
# @return [String] absolute schedule path
|
|
11
|
+
attr_reader :path
|
|
12
|
+
|
|
13
|
+
# @return [Wheneverd::Schedule] schedule being built during evaluation
|
|
14
|
+
attr_reader :schedule
|
|
15
|
+
|
|
16
|
+
# @param path [String]
|
|
17
|
+
def initialize(path:)
|
|
18
|
+
@path = path
|
|
19
|
+
@schedule = Wheneverd::Schedule.new
|
|
20
|
+
@current_entry = nil
|
|
21
|
+
@period_parser = Wheneverd::DSL::PeriodParser.new(path: path)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Define a scheduled entry and evaluate its jobs block.
|
|
25
|
+
#
|
|
26
|
+
# @param periods [Array<String, Symbol, Wheneverd::Duration, Array<Symbol>>]
|
|
27
|
+
# @param at [String, Array<String>, nil]
|
|
28
|
+
# @param roles [Object] stored but currently not used for filtering
|
|
29
|
+
# @return [Wheneverd::Entry]
|
|
30
|
+
def every(*periods, at: nil, roles: nil, &block)
|
|
31
|
+
raise InvalidPeriodError.new("every() requires a block", path: path) unless block
|
|
32
|
+
|
|
33
|
+
raise InvalidPeriodError.new("every() requires a period", path: path) if periods.empty?
|
|
34
|
+
|
|
35
|
+
period = periods.length == 1 ? periods.first : periods
|
|
36
|
+
trigger = @period_parser.trigger_for(period, at: at)
|
|
37
|
+
entry = Wheneverd::Entry.new(trigger: trigger, roles: roles)
|
|
38
|
+
|
|
39
|
+
schedule.add_entry(entry)
|
|
40
|
+
|
|
41
|
+
with_current_entry(entry) { instance_eval(&block) }
|
|
42
|
+
|
|
43
|
+
entry
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Add a oneshot command job to the current `every` entry.
|
|
47
|
+
#
|
|
48
|
+
# @param command_str [String]
|
|
49
|
+
# @return [void]
|
|
50
|
+
def command(command_str)
|
|
51
|
+
unless @current_entry
|
|
52
|
+
raise LoadError.new("command() must be called inside every() block",
|
|
53
|
+
path: path)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
@current_entry.add_job(Wheneverd::Job::Command.new(command: command_str))
|
|
57
|
+
rescue Wheneverd::InvalidCommandError => e
|
|
58
|
+
raise LoadError.new(e.message, path: path)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def with_current_entry(entry)
|
|
64
|
+
previous_entry = @current_entry
|
|
65
|
+
@current_entry = entry
|
|
66
|
+
yield
|
|
67
|
+
ensure
|
|
68
|
+
@current_entry = previous_entry
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
module DSL
|
|
5
|
+
# Base error class for schedule DSL problems.
|
|
6
|
+
#
|
|
7
|
+
# These errors include the path to the schedule file to make CLI output more actionable.
|
|
8
|
+
class Error < Wheneverd::Error
|
|
9
|
+
# @return [String]
|
|
10
|
+
attr_reader :path
|
|
11
|
+
|
|
12
|
+
# @param message [String]
|
|
13
|
+
# @param path [String]
|
|
14
|
+
def initialize(message, path:)
|
|
15
|
+
super(message)
|
|
16
|
+
@path = path
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Raised when a schedule file cannot be evaluated or is invalid.
|
|
21
|
+
class LoadError < Error; end
|
|
22
|
+
|
|
23
|
+
# Raised when an `every(...)` period cannot be parsed or validated.
|
|
24
|
+
class InvalidPeriodError < Error; end
|
|
25
|
+
|
|
26
|
+
# Raised when `at:` times cannot be validated.
|
|
27
|
+
class InvalidAtError < Error; end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
module DSL
|
|
5
|
+
# Loads a schedule file into a {Wheneverd::Schedule}.
|
|
6
|
+
#
|
|
7
|
+
# This evaluates the file as Ruby in an isolated DSL context, and wraps errors with the
|
|
8
|
+
# schedule path for clearer CLI output.
|
|
9
|
+
#
|
|
10
|
+
# Note that schedules are arbitrary Ruby code. Do not load untrusted schedule files.
|
|
11
|
+
class Loader
|
|
12
|
+
# Load and evaluate a schedule file.
|
|
13
|
+
#
|
|
14
|
+
# @param path [String]
|
|
15
|
+
# @return [Wheneverd::Schedule]
|
|
16
|
+
def self.load_file(path)
|
|
17
|
+
absolute_path = File.expand_path(path.to_s)
|
|
18
|
+
evaluate_file(absolute_path)
|
|
19
|
+
rescue Wheneverd::DSL::Error => e
|
|
20
|
+
raise_with_path(e, absolute_path)
|
|
21
|
+
rescue Wheneverd::Error, StandardError => e
|
|
22
|
+
raise_load_error(e, absolute_path)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.evaluate_file(absolute_path)
|
|
26
|
+
context = Wheneverd::DSL::Context.new(path: absolute_path)
|
|
27
|
+
source = File.read(absolute_path)
|
|
28
|
+
|
|
29
|
+
context.instance_eval(source, absolute_path, 1)
|
|
30
|
+
context.schedule
|
|
31
|
+
end
|
|
32
|
+
private_class_method :evaluate_file
|
|
33
|
+
|
|
34
|
+
def self.raise_with_path(error, absolute_path)
|
|
35
|
+
wrapped = error.class.new("#{absolute_path}: #{error.message}", path: absolute_path)
|
|
36
|
+
wrapped.set_backtrace(error.backtrace)
|
|
37
|
+
raise wrapped
|
|
38
|
+
end
|
|
39
|
+
private_class_method :raise_with_path
|
|
40
|
+
|
|
41
|
+
def self.raise_load_error(error, absolute_path)
|
|
42
|
+
wrapped = LoadError.new("#{absolute_path}: #{error.message}", path: absolute_path)
|
|
43
|
+
wrapped.set_backtrace(error.backtrace)
|
|
44
|
+
raise wrapped
|
|
45
|
+
end
|
|
46
|
+
private_class_method :raise_load_error
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "calendar_symbol_period_list"
|
|
4
|
+
|
|
5
|
+
module Wheneverd
|
|
6
|
+
module DSL
|
|
7
|
+
# Converts DSL `every(...)` period values into trigger objects.
|
|
8
|
+
#
|
|
9
|
+
# Supported period forms are described in the README.
|
|
10
|
+
#
|
|
11
|
+
# Notes:
|
|
12
|
+
#
|
|
13
|
+
# - Interval strings and {Wheneverd::Duration} values produce monotonic triggers
|
|
14
|
+
# ({Wheneverd::Trigger::Interval}).
|
|
15
|
+
# - Calendar symbol periods and cron strings produce calendar triggers
|
|
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.
|
|
19
|
+
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
|
+
attr_reader :path
|
|
30
|
+
|
|
31
|
+
# @param path [String] schedule path for error reporting
|
|
32
|
+
def initialize(path:)
|
|
33
|
+
@path = path
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param period [String, Symbol, Array<Symbol>, Wheneverd::Duration]
|
|
37
|
+
# @param at [String, Array<String>, nil]
|
|
38
|
+
# @return [Wheneverd::Trigger::Interval, Wheneverd::Trigger::Calendar]
|
|
39
|
+
def trigger_for(period, at:)
|
|
40
|
+
at_times = AtNormalizer.normalize(at, path: path)
|
|
41
|
+
trigger_for_period(period, at_times: at_times)
|
|
42
|
+
rescue Wheneverd::InvalidIntervalError => e
|
|
43
|
+
raise InvalidPeriodError.new(e.message, path: path)
|
|
44
|
+
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
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
# A positive duration represented as a whole number of seconds.
|
|
5
|
+
#
|
|
6
|
+
# This type is produced by the `Numeric` helpers from {Wheneverd::CoreExt::NumericDuration}
|
|
7
|
+
# (for example, `5.minutes`), and is used by the DSL period parser.
|
|
8
|
+
class Duration
|
|
9
|
+
# @return [Integer] duration in seconds
|
|
10
|
+
attr_reader :seconds
|
|
11
|
+
|
|
12
|
+
# @param seconds [Integer] duration in seconds (must be positive)
|
|
13
|
+
def initialize(seconds)
|
|
14
|
+
unless seconds.is_a?(Integer)
|
|
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
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_i
|
|
24
|
+
seconds
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
# A single scheduled unit of work.
|
|
5
|
+
#
|
|
6
|
+
# An entry ties together a trigger (when to run) and one or more jobs (what to run).
|
|
7
|
+
class Entry
|
|
8
|
+
# @return [Wheneverd::Trigger::Interval, Wheneverd::Trigger::Calendar, Wheneverd::Trigger::Boot]
|
|
9
|
+
attr_reader :trigger, :jobs, :roles
|
|
10
|
+
|
|
11
|
+
# @param trigger [Object] a trigger object describing when to run
|
|
12
|
+
# @param jobs [Array<Object>] job objects (usually {Wheneverd::Job::Command})
|
|
13
|
+
# @param roles [Object] stored but currently not used for filtering
|
|
14
|
+
def initialize(trigger:, jobs: [], roles: nil)
|
|
15
|
+
raise ArgumentError, "trigger is required" if trigger.nil?
|
|
16
|
+
|
|
17
|
+
@trigger = trigger
|
|
18
|
+
@jobs = jobs.dup
|
|
19
|
+
@roles = roles
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Append a job to the entry.
|
|
23
|
+
#
|
|
24
|
+
# @param job [Object]
|
|
25
|
+
# @return [Entry] self
|
|
26
|
+
def add_job(job)
|
|
27
|
+
jobs << job
|
|
28
|
+
self
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
# Raised when {Wheneverd::Interval.parse} cannot parse or validate an interval string.
|
|
5
|
+
class InvalidIntervalError < Error; end
|
|
6
|
+
|
|
7
|
+
# Raised when a {Wheneverd::Job::Command} is created with an invalid command string.
|
|
8
|
+
class InvalidCommandError < Error; end
|
|
9
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
# Parser for compact interval strings used by the DSL.
|
|
5
|
+
#
|
|
6
|
+
# The supported format is `"<n>s|m|h|d|w"`, for example `"5m"` or `"1h"`.
|
|
7
|
+
module Interval
|
|
8
|
+
MULTIPLIERS = {
|
|
9
|
+
"s" => 1,
|
|
10
|
+
"m" => 60,
|
|
11
|
+
"h" => 60 * 60,
|
|
12
|
+
"d" => 60 * 60 * 24,
|
|
13
|
+
"w" => 60 * 60 * 24 * 7
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
FORMAT = /\A(?<n>-?\d+)(?<unit>[smhdw])\z/.freeze
|
|
17
|
+
|
|
18
|
+
# Parse an interval string into seconds.
|
|
19
|
+
#
|
|
20
|
+
# @param str [String] interval like `"5m"`
|
|
21
|
+
# @return [Integer] seconds
|
|
22
|
+
# @raise [Wheneverd::InvalidIntervalError] if the input is invalid
|
|
23
|
+
def self.parse(str)
|
|
24
|
+
input = str.to_s.strip
|
|
25
|
+
match = FORMAT.match(input)
|
|
26
|
+
unless match
|
|
27
|
+
raise InvalidIntervalError,
|
|
28
|
+
"Invalid interval #{input.inspect}; expected <n>s|m|h|d|w (example: \"5m\")"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
n = Integer(match[:n], 10)
|
|
32
|
+
raise InvalidIntervalError, "Interval must be positive (got #{input.inspect})" if n <= 0
|
|
33
|
+
|
|
34
|
+
n * MULTIPLIERS.fetch(match[:unit])
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
module Job
|
|
5
|
+
# A oneshot command job rendered as `ExecStart=` in a `systemd` service unit.
|
|
6
|
+
#
|
|
7
|
+
# Note that the command is inserted into `ExecStart=` as-is. If you need shell features like
|
|
8
|
+
# pipes, redirects, or environment variable expansion, wrap the command explicitly:
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# command "/bin/bash -lc 'echo hello | sed -e s/hello/hi/'"
|
|
12
|
+
class Command
|
|
13
|
+
# @return [String]
|
|
14
|
+
attr_reader :command
|
|
15
|
+
|
|
16
|
+
# @param command [String] non-empty command to run
|
|
17
|
+
def initialize(command:)
|
|
18
|
+
unless command.is_a?(String)
|
|
19
|
+
raise InvalidCommandError, "Command must be a String (got #{command.class})"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
command_stripped = command.strip
|
|
23
|
+
raise InvalidCommandError, "Command must not be empty" if command_stripped.empty?
|
|
24
|
+
|
|
25
|
+
@command = command_stripped
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
# A schedule is an ordered list of {Entry} objects.
|
|
5
|
+
#
|
|
6
|
+
# Schedules are typically created by evaluating a schedule file in the DSL context.
|
|
7
|
+
class Schedule
|
|
8
|
+
# @return [Array<Entry>]
|
|
9
|
+
attr_reader :entries
|
|
10
|
+
|
|
11
|
+
# @param entries [Array<Entry>]
|
|
12
|
+
def initialize(entries: [])
|
|
13
|
+
@entries = entries.dup
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Append an entry to the schedule.
|
|
17
|
+
#
|
|
18
|
+
# @param entry [Entry]
|
|
19
|
+
# @return [Schedule] self
|
|
20
|
+
def add_entry(entry)
|
|
21
|
+
entries << entry
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
module Systemd
|
|
5
|
+
# Translates higher-level schedule period specs into systemd `OnCalendar=` values.
|
|
6
|
+
#
|
|
7
|
+
# The DSL produces values like `"day@4:30 am"` or `"cron:0 0 27-31 * *"`, which are normalized
|
|
8
|
+
# here into systemd-friendly calendar expressions.
|
|
9
|
+
module CalendarSpec
|
|
10
|
+
BASE_ALIASES = {
|
|
11
|
+
"hour" => "hourly",
|
|
12
|
+
"day" => "daily",
|
|
13
|
+
"month" => "monthly",
|
|
14
|
+
"year" => "yearly"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
PREFIXES = {
|
|
18
|
+
"day" => "*-*-*",
|
|
19
|
+
"weekday" => "Mon..Fri *-*-*",
|
|
20
|
+
"weekend" => "Sat,Sun *-*-*"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
WEEKDAYS = {
|
|
24
|
+
"monday" => "Mon",
|
|
25
|
+
"tuesday" => "Tue",
|
|
26
|
+
"wednesday" => "Wed",
|
|
27
|
+
"thursday" => "Thu",
|
|
28
|
+
"friday" => "Fri",
|
|
29
|
+
"saturday" => "Sat",
|
|
30
|
+
"sunday" => "Sun"
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# Convert a calendar spec into a systemd `OnCalendar=` value.
|
|
34
|
+
#
|
|
35
|
+
# @param spec [String]
|
|
36
|
+
# @return [String]
|
|
37
|
+
# @raise [Wheneverd::Systemd::InvalidCalendarSpecError]
|
|
38
|
+
def self.to_on_calendar(spec)
|
|
39
|
+
values = to_on_calendar_values(spec)
|
|
40
|
+
return values.fetch(0) if values.length == 1
|
|
41
|
+
|
|
42
|
+
message =
|
|
43
|
+
"Invalid calendar spec: #{spec.to_s.strip.inspect} " \
|
|
44
|
+
"expands to multiple OnCalendar values"
|
|
45
|
+
raise InvalidCalendarSpecError, message
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Convert a calendar spec into one or more systemd `OnCalendar=` values.
|
|
49
|
+
#
|
|
50
|
+
# Some inputs (e.g., certain cron expressions) may require multiple `OnCalendar=` entries
|
|
51
|
+
# to preserve semantics.
|
|
52
|
+
#
|
|
53
|
+
# @param spec [String]
|
|
54
|
+
# @return [Array<String>]
|
|
55
|
+
# @raise [Wheneverd::Systemd::InvalidCalendarSpecError]
|
|
56
|
+
def self.to_on_calendar_values(spec)
|
|
57
|
+
input = spec.to_s.strip
|
|
58
|
+
raise InvalidCalendarSpecError, "Invalid calendar spec: empty" if input.empty?
|
|
59
|
+
|
|
60
|
+
return cron_to_on_calendar_values(input) if input.start_with?("cron:")
|
|
61
|
+
|
|
62
|
+
base, at = input.split("@", 2)
|
|
63
|
+
base = base.strip
|
|
64
|
+
|
|
65
|
+
raise InvalidCalendarSpecError, "Invalid calendar spec: #{input.inspect}" if base.empty?
|
|
66
|
+
|
|
67
|
+
[translate_base_with_optional_at(base, at)]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.translate_base_with_optional_at(base, at)
|
|
71
|
+
return base_to_systemd(base) if at.nil?
|
|
72
|
+
|
|
73
|
+
time = Wheneverd::Systemd::TimeParser.parse(at)
|
|
74
|
+
"#{time_prefix_for_at(base)} #{time}"
|
|
75
|
+
end
|
|
76
|
+
private_class_method :translate_base_with_optional_at
|
|
77
|
+
|
|
78
|
+
def self.base_to_systemd(base)
|
|
79
|
+
return BASE_ALIASES.fetch(base) if BASE_ALIASES.key?(base)
|
|
80
|
+
|
|
81
|
+
"#{time_prefix_for_midnight(base)} 00:00:00"
|
|
82
|
+
end
|
|
83
|
+
private_class_method :base_to_systemd
|
|
84
|
+
|
|
85
|
+
def self.cron_to_on_calendar_values(input)
|
|
86
|
+
cron = input.delete_prefix("cron:")
|
|
87
|
+
Wheneverd::Systemd::CronParser.to_on_calendar_values(cron)
|
|
88
|
+
end
|
|
89
|
+
private_class_method :cron_to_on_calendar_values
|
|
90
|
+
|
|
91
|
+
def self.time_prefix_for_at(base)
|
|
92
|
+
return PREFIXES.fetch(base) if PREFIXES.key?(base)
|
|
93
|
+
return "#{WEEKDAYS.fetch(base)} *-*-*" if WEEKDAYS.key?(base)
|
|
94
|
+
|
|
95
|
+
raise InvalidCalendarSpecError,
|
|
96
|
+
"Invalid calendar spec: #{base.inspect} does not support @time"
|
|
97
|
+
end
|
|
98
|
+
private_class_method :time_prefix_for_at
|
|
99
|
+
|
|
100
|
+
def self.time_prefix_for_midnight(base)
|
|
101
|
+
return PREFIXES.fetch(base) if PREFIXES.key?(base)
|
|
102
|
+
return "#{WEEKDAYS.fetch(base)} *-*-*" if WEEKDAYS.key?(base)
|
|
103
|
+
|
|
104
|
+
raise InvalidCalendarSpecError, "Invalid calendar spec: #{base.inspect}"
|
|
105
|
+
end
|
|
106
|
+
private_class_method :time_prefix_for_midnight
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|