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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ff946dbc0ba39d3e90ecf3cbed4c9ea81c1818fcd1d206f00cf6763dfe05ebe1
|
|
4
|
+
data.tar.gz: cd42856421c97365ff63547252ea4e8ba8a5f17dc822eea8af67b45ce8861b8f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a4d31fc560c96e1b10b7b42cb0c32e4aa222823e64bde6a17cacb891246d67caefb9e42b5f71ce23a26b14392c33eb819f42fc3e199a0b2f10a0f040bb5e5193
|
|
7
|
+
data.tar.gz: fc2e7a43ed41af1b7b00cb4cc8a228054071cec67ca53bb81829134ab884f8a6b5730796a699bdd6066262d3fee18ff0234f535d2fe967b5d26543d2d7b2097e
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,16 @@ On release, entries are moved into `## x.y.z` sections that match the gem versio
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
+
## 0.4.0
|
|
9
|
+
|
|
10
|
+
- Docs: adds a copy/paste "deploy a simple schedule" example and refines README status section.
|
|
11
|
+
- Refactor: extracts `UnitPathUtils` module for shared identifier/path utilities across `UnitWriter`, `UnitDeleter`, `UnitLister`, and `Renderer`.
|
|
12
|
+
- Refactor: adds polymorphic `Trigger::Base` interface with `#systemd_timer_lines` and `#signature` methods for all trigger types.
|
|
13
|
+
- Refactor: splits `CronParser` into focused `FieldParser` and `DowParser` submodules for maintainability.
|
|
14
|
+
- Refactor: implements strategy pattern for `PeriodParser` with dedicated strategies for Duration, String, Symbol, and Array inputs.
|
|
15
|
+
- Refactor: extracts `UnitContentBuilder` from `Renderer` for cleaner separation of unit content generation.
|
|
16
|
+
- Refactor: adds `Validation` module with composable validators (`type`, `positive_integer`, `non_empty_string`, `non_empty_array`, `in_range`).
|
|
17
|
+
|
|
8
18
|
## 0.3.0
|
|
9
19
|
|
|
10
20
|
- Schedule DSL: `command` accepts argv arrays, adds a `shell` helper for `/bin/bash -lc`, and `wheneverd init` includes examples.
|
data/README.md
CHANGED
|
@@ -4,14 +4,23 @@ Wheneverd is to systemd timers what the [`whenever` gem](https://github.com/java
|
|
|
4
4
|
|
|
5
5
|
## Status
|
|
6
6
|
|
|
7
|
-
Pre-1.0, but working end-to-end for user
|
|
7
|
+
Pre-1.0, but working end-to-end for systemd user timers on Linux:
|
|
8
8
|
|
|
9
9
|
- Loads a Ruby schedule DSL file (default: `config/schedule.rb`).
|
|
10
10
|
- Renders systemd `.service`/`.timer` units (interval, calendar, and 5-field cron schedules).
|
|
11
|
-
- Writes,
|
|
11
|
+
- Writes, diffs, shows, and deletes generated unit files (default: `~/.config/systemd/user`).
|
|
12
12
|
- Enables/starts/stops/disables/restarts timers via `systemctl --user`.
|
|
13
|
+
- Validates `OnCalendar=` values with `systemd-analyze` (optional unit verification).
|
|
13
14
|
- Manages lingering via `loginctl` (so timers can run while logged out).
|
|
14
15
|
|
|
16
|
+
Non-goals / not yet implemented:
|
|
17
|
+
|
|
18
|
+
- System-level units (`/etc/systemd/system`) / `systemctl` without `--user`.
|
|
19
|
+
- Non-systemd schedulers (cron, launchd, etc).
|
|
20
|
+
- Non-Linux platforms (no Windows/macOS support).
|
|
21
|
+
|
|
22
|
+
Expect the CLI and generated unit details to change until 1.0.
|
|
23
|
+
|
|
15
24
|
See `FEATURE_SUMMARY.md` for user-visible behavior, and `CHANGELOG.md` for release notes.
|
|
16
25
|
|
|
17
26
|
## Installation
|
|
@@ -62,6 +71,40 @@ every 1.day, at: "4:30 am" do
|
|
|
62
71
|
end
|
|
63
72
|
```
|
|
64
73
|
|
|
74
|
+
### Deploy a simple schedule (copy/paste)
|
|
75
|
+
|
|
76
|
+
From your project root (the default identifier is the current directory name):
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
# Install (skip if already in your Gemfile)
|
|
80
|
+
bundle add wheneverd
|
|
81
|
+
bundle install
|
|
82
|
+
|
|
83
|
+
# Write a schedule that appends a timestamp to ~/.cache/wheneverd-demo.log every minute
|
|
84
|
+
mkdir -p config
|
|
85
|
+
cat > config/schedule.rb <<'RUBY'
|
|
86
|
+
# frozen_string_literal: true
|
|
87
|
+
|
|
88
|
+
every "1m" do
|
|
89
|
+
shell "mkdir -p ~/.cache && date >> ~/.cache/wheneverd-demo.log"
|
|
90
|
+
end
|
|
91
|
+
RUBY
|
|
92
|
+
|
|
93
|
+
# Preview, write units, and enable/start the timer(s)
|
|
94
|
+
bundle exec wheneverd show
|
|
95
|
+
bundle exec wheneverd validate
|
|
96
|
+
bundle exec wheneverd write
|
|
97
|
+
bundle exec wheneverd activate
|
|
98
|
+
|
|
99
|
+
# Verify it’s installed and running
|
|
100
|
+
bundle exec wheneverd status
|
|
101
|
+
tail -n 5 ~/.cache/wheneverd-demo.log
|
|
102
|
+
|
|
103
|
+
# Stop/disable timers and remove generated unit files
|
|
104
|
+
bundle exec wheneverd deactivate
|
|
105
|
+
bundle exec wheneverd delete
|
|
106
|
+
```
|
|
107
|
+
|
|
65
108
|
Preview the generated units:
|
|
66
109
|
|
|
67
110
|
```bash
|
|
@@ -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
|