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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +16 -0
  4. data/Gemfile.lock +38 -33
  5. data/README.md +64 -7
  6. data/lib/wheneverd/cli/activate.rb +5 -5
  7. data/lib/wheneverd/cli/deactivate.rb +6 -6
  8. data/lib/wheneverd/cli/reload.rb +8 -8
  9. data/lib/wheneverd/cli/status.rb +13 -6
  10. data/lib/wheneverd/cli.rb +10 -8
  11. data/lib/wheneverd/dsl/context.rb +46 -0
  12. data/lib/wheneverd/dsl/period_parser.rb +8 -107
  13. data/lib/wheneverd/dsl/period_strategy/array_strategy.rb +29 -0
  14. data/lib/wheneverd/dsl/period_strategy/base.rb +65 -0
  15. data/lib/wheneverd/dsl/period_strategy/duration_strategy.rb +33 -0
  16. data/lib/wheneverd/dsl/period_strategy/string_strategy.rb +51 -0
  17. data/lib/wheneverd/dsl/period_strategy/symbol_strategy.rb +31 -0
  18. data/lib/wheneverd/dsl/period_strategy.rb +43 -0
  19. data/lib/wheneverd/duration.rb +1 -7
  20. data/lib/wheneverd/errors.rb +3 -0
  21. data/lib/wheneverd/interval.rb +22 -7
  22. data/lib/wheneverd/schedule.rb +15 -1
  23. data/lib/wheneverd/service.rb +105 -0
  24. data/lib/wheneverd/systemd/cron_parser/dow_parser.rb +208 -0
  25. data/lib/wheneverd/systemd/cron_parser/field_parser.rb +163 -0
  26. data/lib/wheneverd/systemd/cron_parser.rb +56 -303
  27. data/lib/wheneverd/systemd/renderer.rb +31 -66
  28. data/lib/wheneverd/systemd/unit_content_builder.rb +99 -0
  29. data/lib/wheneverd/systemd/unit_deleter.rb +2 -28
  30. data/lib/wheneverd/systemd/unit_lister.rb +2 -28
  31. data/lib/wheneverd/systemd/unit_namer.rb +6 -15
  32. data/lib/wheneverd/systemd/unit_path_utils.rb +54 -0
  33. data/lib/wheneverd/systemd/unit_writer.rb +2 -28
  34. data/lib/wheneverd/trigger/base.rb +22 -0
  35. data/lib/wheneverd/trigger/boot.rb +8 -6
  36. data/lib/wheneverd/trigger/calendar.rb +7 -0
  37. data/lib/wheneverd/trigger/interval.rb +8 -6
  38. data/lib/wheneverd/validation.rb +89 -0
  39. data/lib/wheneverd/version.rb +1 -1
  40. data/lib/wheneverd.rb +5 -1
  41. data/test/cli_activate_test.rb +27 -0
  42. data/test/cli_reload_test.rb +23 -0
  43. data/test/cli_status_test.rb +14 -4
  44. data/test/domain_model_test.rb +105 -0
  45. data/test/dsl_context_shell_test.rb +31 -0
  46. data/test/systemd_cron_parser_test.rb +41 -25
  47. data/test/systemd_renderer_errors_test.rb +1 -1
  48. data/test/systemd_renderer_test.rb +73 -0
  49. metadata +16 -2
@@ -105,4 +105,109 @@ class DomainModelTest < Minitest::Test
105
105
  def test_entry_requires_a_trigger
106
106
  assert_raises(ArgumentError) { Wheneverd::Entry.new(trigger: nil) }
107
107
  end
108
+
109
+ def test_trigger_base_module_raises_not_implemented
110
+ # Create a class that includes Base but doesn't implement the methods
111
+ test_trigger_class = Class.new do
112
+ include Wheneverd::Trigger::Base
113
+ end
114
+ trigger = test_trigger_class.new
115
+
116
+ error = assert_raises(NotImplementedError) { trigger.systemd_timer_lines }
117
+ assert_includes error.message, "must implement #systemd_timer_lines"
118
+
119
+ error = assert_raises(NotImplementedError) { trigger.signature }
120
+ assert_includes error.message, "must implement #signature"
121
+ end
122
+
123
+ def test_trigger_signatures
124
+ interval = Wheneverd::Trigger::Interval.new(seconds: 60)
125
+ assert_equal "interval:60", interval.signature
126
+
127
+ boot = Wheneverd::Trigger::Boot.new(seconds: 5)
128
+ assert_equal "boot:5", boot.signature
129
+
130
+ calendar = Wheneverd::Trigger::Calendar.new(on_calendar: %w[daily hourly])
131
+ assert_equal "calendar:daily|hourly", calendar.signature
132
+ end
133
+
134
+ def test_period_strategy_base_raises_not_implemented
135
+ # Create a class that inherits from Base but doesn't implement the methods
136
+ test_strategy_class = Class.new(Wheneverd::DSL::PeriodStrategy::Base)
137
+ strategy = test_strategy_class.new(path: "test")
138
+
139
+ error = assert_raises(NotImplementedError) { strategy.handles?(:anything) }
140
+ assert_includes error.message, "must implement #handles?"
141
+
142
+ error = assert_raises(NotImplementedError) { strategy.parse(:anything, at_times: []) }
143
+ assert_includes error.message, "must implement #parse"
144
+ end
145
+
146
+ def test_validation_type
147
+ # Valid type
148
+ assert_equal 42, Wheneverd::Validation.type(42, Integer, name: "value")
149
+
150
+ # Invalid type
151
+ error = assert_raises(ArgumentError) do
152
+ Wheneverd::Validation.type("string", Integer, name: "value")
153
+ end
154
+ assert_includes error.message, "value must be a Integer"
155
+ end
156
+
157
+ def test_validation_positive_integer
158
+ # Valid positive integer
159
+ assert_equal 5, Wheneverd::Validation.positive_integer(5, name: "count")
160
+
161
+ # Non-positive
162
+ error = assert_raises(ArgumentError) do
163
+ Wheneverd::Validation.positive_integer(0, name: "count")
164
+ end
165
+ assert_includes error.message, "must be positive"
166
+
167
+ # Non-integer
168
+ error = assert_raises(ArgumentError) do
169
+ Wheneverd::Validation.positive_integer("5", name: "count")
170
+ end
171
+ assert_includes error.message, "must be a Integer"
172
+ end
173
+
174
+ def test_validation_non_empty_string
175
+ # Valid non-empty string
176
+ assert_equal "hello", Wheneverd::Validation.non_empty_string(" hello ", name: "text")
177
+
178
+ # Empty string
179
+ error = assert_raises(ArgumentError) do
180
+ Wheneverd::Validation.non_empty_string(" ", name: "text")
181
+ end
182
+ assert_includes error.message, "must not be empty"
183
+ end
184
+
185
+ def test_validation_non_empty_array
186
+ # Valid non-empty array
187
+ arr = [1, 2, 3]
188
+ assert_equal arr, Wheneverd::Validation.non_empty_array(arr, name: "items")
189
+
190
+ # Empty array
191
+ error = assert_raises(ArgumentError) do
192
+ Wheneverd::Validation.non_empty_array([], name: "items")
193
+ end
194
+ assert_includes error.message, "must not be empty"
195
+
196
+ # Non-array
197
+ error = assert_raises(ArgumentError) do
198
+ Wheneverd::Validation.non_empty_array("not an array", name: "items")
199
+ end
200
+ assert_includes error.message, "must be a Array"
201
+ end
202
+
203
+ def test_validation_in_range
204
+ # Valid in range
205
+ assert_equal 5, Wheneverd::Validation.in_range(5, 1..10, name: "value")
206
+
207
+ # Out of range
208
+ error = assert_raises(ArgumentError) do
209
+ Wheneverd::Validation.in_range(15, 1..10, name: "value")
210
+ end
211
+ assert_includes error.message, "must be in 1..10"
212
+ end
108
213
  end
@@ -15,6 +15,37 @@ class DSLContextShellTest < Minitest::Test
15
15
  assert_includes error.message, "shell() script must be a String"
16
16
  end
17
17
 
18
+ def test_service_accepts_shell_command
19
+ ctx = Wheneverd::DSL::Context.new(path: "/tmp/config/schedule.rb")
20
+ ctx.service("live-poller", shell: "bundle exec bin/live")
21
+
22
+ service = ctx.schedule.services.fetch(0)
23
+ assert_equal "live-poller", service.name
24
+ assert_equal ["/bin/bash", "-lc", "bundle exec bin/live"], service.command.argv
25
+ end
26
+
27
+ def test_service_requires_command_or_shell
28
+ ctx = Wheneverd::DSL::Context.new(path: "/tmp/config/schedule.rb")
29
+ error = assert_raises(Wheneverd::DSL::LoadError) { ctx.service("live-poller") }
30
+ assert_includes error.message, "service() requires command: or shell:"
31
+ end
32
+
33
+ def test_service_rejects_command_and_shell_together
34
+ ctx = Wheneverd::DSL::Context.new(path: "/tmp/config/schedule.rb")
35
+ error = assert_raises(Wheneverd::DSL::LoadError) do
36
+ ctx.service("live-poller", command: "bin/live", shell: "bin/live")
37
+ end
38
+ assert_includes error.message, "service() accepts command: or shell:, not both"
39
+ end
40
+
41
+ def test_service_wraps_validation_errors_as_load_errors
42
+ ctx = Wheneverd::DSL::Context.new(path: "/tmp/config/schedule.rb")
43
+ error = assert_raises(Wheneverd::DSL::LoadError) do
44
+ ctx.service("live-poller", command: "bin/live", service: { "Bad-Key" => "1" })
45
+ end
46
+ assert_includes error.message, "Invalid service setting name"
47
+ end
48
+
18
49
  def test_shell_rejects_empty_script
19
50
  ctx = Wheneverd::DSL::Context.new(path: "/tmp/config/schedule.rb")
20
51
  error = assert_raises(Wheneverd::DSL::LoadError) { ctx.every("1m") { shell(" ") } }
@@ -38,28 +38,51 @@ class SystemdCronParserTest < Minitest::Test
38
38
  end
39
39
  end
40
40
 
41
- def test_private_helpers_reject_empty_numeric_field
42
- assert_private_unsupported(
43
- :parse_mapped_numeric_expression,
44
- "",
45
- 0..59,
46
- field: "minute",
47
- input: "x",
48
- names: {}
49
- )
41
+ def test_field_parser_rejects_empty_numeric_field
42
+ assert_raises(Wheneverd::Systemd::UnsupportedCronError) do
43
+ Wheneverd::Systemd::CronParser::FieldParser.parse_mapped(
44
+ "",
45
+ 0..59,
46
+ field: "minute",
47
+ input: "x",
48
+ names: {}
49
+ )
50
+ end
50
51
  end
51
52
 
52
- def test_private_helpers_reject_invalid_numeric_tokens
53
- common = { field: "minute", input: "x", names: {} }
54
- assert_private_unsupported(:parse_mapped_value, " ", 0..59, **common)
55
- assert_private_unsupported(:parse_positive_int, "x", field: "minute", input: "x", label: "step")
53
+ def test_field_parser_rejects_invalid_numeric_tokens
54
+ # Empty token (just whitespace)
55
+ assert_raises(Wheneverd::Systemd::UnsupportedCronError) do
56
+ Wheneverd::Systemd::CronParser::FieldParser.parse_mapped(
57
+ " ",
58
+ 0..59,
59
+ field: "minute",
60
+ input: "x",
61
+ names: {}
62
+ )
63
+ end
56
64
  end
57
65
 
58
- def test_private_helpers_reject_empty_dow_and_invalid_tokens
59
- assert_private_unsupported(:parse_dow_set, "", input: "x")
60
- assert_private_unsupported(:apply_dow_part, Array.new(7, false), "", input: "x")
61
- assert_private_unsupported(:parse_dow_value, "", input: "x")
62
- assert_private_unsupported(:parse_dow_value, "8", input: "x")
66
+ def test_dow_parser_rejects_empty_and_invalid_tokens
67
+ # Empty day-of-week field
68
+ assert_raises(Wheneverd::Systemd::UnsupportedCronError) do
69
+ Wheneverd::Systemd::CronParser::DowParser.parse("", input: "x")
70
+ end
71
+
72
+ # Out of range day-of-week value (8)
73
+ assert_raises(Wheneverd::Systemd::UnsupportedCronError) do
74
+ Wheneverd::Systemd::CronParser::DowParser.parse("8", input: "x")
75
+ end
76
+
77
+ # Invalid step (non-numeric)
78
+ assert_raises(Wheneverd::Systemd::UnsupportedCronError) do
79
+ Wheneverd::Systemd::CronParser::DowParser.parse("*/x", input: "x")
80
+ end
81
+
82
+ # Invalid step (zero)
83
+ assert_raises(Wheneverd::Systemd::UnsupportedCronError) do
84
+ Wheneverd::Systemd::CronParser::DowParser.parse("*/0", input: "x")
85
+ end
63
86
  end
64
87
 
65
88
  def test_rejects_unsupported_cron_patterns
@@ -97,13 +120,6 @@ class SystemdCronParserTest < Minitest::Test
97
120
  assert_equal expected, Wheneverd::Systemd::CronParser.to_on_calendar_values(cron)
98
121
  end
99
122
 
100
- def assert_private_unsupported(method, *args, **kwargs)
101
- parser = Wheneverd::Systemd::CronParser
102
- assert_raises(Wheneverd::Systemd::UnsupportedCronError) do
103
- parser.send(method, *args, **kwargs)
104
- end
105
- end
106
-
107
123
  def assert_unsupported(*crons)
108
124
  crons.each do |cron|
109
125
  assert_raises(Wheneverd::Systemd::UnsupportedCronError) do
@@ -44,7 +44,7 @@ class SystemdRendererErrorsTest < Minitest::Test
44
44
 
45
45
  def test_timer_lines_for_rejects_unknown_trigger
46
46
  assert_raises(ArgumentError) do
47
- Wheneverd::Systemd::Renderer.send(:timer_lines_for, Object.new)
47
+ Wheneverd::Systemd::UnitContentBuilder.timer_lines_for(Object.new)
48
48
  end
49
49
  end
50
50
 
@@ -37,11 +37,84 @@ module SystemdRendererTestHelpers
37
37
  )
38
38
  end
39
39
 
40
+ def render_schedule(schedule, identifier: "demo")
41
+ Wheneverd::Systemd::Renderer.render(schedule, identifier: identifier)
42
+ end
43
+
40
44
  def marker
41
45
  "# Generated by wheneverd (wheneverd) #{Wheneverd::VERSION}; do not edit."
42
46
  end
43
47
  end
44
48
 
49
+ class SystemdRendererServiceTest < Minitest::Test
50
+ include SystemdRendererTestHelpers
51
+
52
+ def test_standalone_service_contains_restart_and_extra_service_settings
53
+ service = Wheneverd::Service.new(
54
+ name: "live-poller",
55
+ command: ["/bin/bash", "-lc", "bundle exec bin/live"],
56
+ restart: "always",
57
+ restart_sec: "5s",
58
+ service: { "Nice" => "10", "CPUQuota" => "80%" }
59
+ )
60
+ units = render_schedule(Wheneverd::Schedule.new(services: [service]))
61
+ unit = units.fetch(0)
62
+
63
+ assert_equal :service, unit.kind
64
+ assert_equal :service, unit.activation
65
+ assert_includes unit.contents, marker
66
+ assert_includes unit.contents, "Description=wheneverd service #{unit.path_basename}"
67
+ assert_includes unit.contents, "Type=simple"
68
+ assert_includes unit.contents, 'ExecStart=/bin/bash -lc "bundle exec bin/live"'
69
+ assert_includes unit.contents, "Restart=always"
70
+ assert_includes unit.contents, "RestartSec=5s"
71
+ assert_includes unit.contents, "Nice=10"
72
+ assert_includes unit.contents, "CPUQuota=80%"
73
+ assert_includes unit.contents, "WantedBy=default.target"
74
+ end
75
+
76
+ def test_standalone_service_accepts_array_service_lines
77
+ service = Wheneverd::Service.new(
78
+ name: "live-poller",
79
+ command: "bin/live",
80
+ service: ["Environment=RAILS_ENV=production"]
81
+ )
82
+ unit = render_schedule(Wheneverd::Schedule.new(services: [service])).fetch(0)
83
+
84
+ assert_includes unit.contents, "Environment=RAILS_ENV=production"
85
+ end
86
+
87
+ def test_standalone_service_rejects_invalid_service_lines
88
+ assert_raises(Wheneverd::InvalidCommandError) do
89
+ Wheneverd::Service.new(name: "live-poller", command: "bin/live", service: ["NoEquals"])
90
+ end
91
+ end
92
+
93
+ def test_standalone_service_rejects_invalid_service_settings_collection
94
+ assert_raises(Wheneverd::InvalidCommandError) do
95
+ Wheneverd::Service.new(name: "live-poller", command: "bin/live", service: "Nice=10")
96
+ end
97
+ end
98
+
99
+ def test_standalone_service_rejects_newlines_in_setting_values
100
+ assert_raises(Wheneverd::InvalidCommandError) do
101
+ Wheneverd::Service.new(name: "live-poller", command: "bin/live",
102
+ service: { "Environment" => "A=1\nB=2" })
103
+ end
104
+ end
105
+
106
+ def test_standalone_service_naming_is_stable
107
+ service = Wheneverd::Service.new(name: "worker", command: "bin/worker")
108
+ schedule = Wheneverd::Schedule.new(services: [service])
109
+
110
+ units1 = render_schedule(schedule)
111
+ units2 = render_schedule(schedule)
112
+
113
+ assert_equal units1.map(&:path_basename), units2.map(&:path_basename)
114
+ assert_match(/\Awheneverd-demo-[0-9a-f]{12}\.service\z/, units1.fetch(0).path_basename)
115
+ end
116
+ end
117
+
45
118
  class SystemdRendererIntervalTest < Minitest::Test
46
119
  include SystemdRendererTestHelpers
47
120
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wheneverd
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - bigcurl
@@ -32,6 +32,7 @@ extra_rdoc_files: []
32
32
  files:
33
33
  - ".gitignore"
34
34
  - ".rubocop.yml"
35
+ - ".ruby-version"
35
36
  - ".yardopts"
36
37
  - AGENTS.md
37
38
  - CHANGELOG.md
@@ -66,27 +67,40 @@ files:
66
67
  - lib/wheneverd/dsl/errors.rb
67
68
  - lib/wheneverd/dsl/loader.rb
68
69
  - lib/wheneverd/dsl/period_parser.rb
70
+ - lib/wheneverd/dsl/period_strategy.rb
71
+ - lib/wheneverd/dsl/period_strategy/array_strategy.rb
72
+ - lib/wheneverd/dsl/period_strategy/base.rb
73
+ - lib/wheneverd/dsl/period_strategy/duration_strategy.rb
74
+ - lib/wheneverd/dsl/period_strategy/string_strategy.rb
75
+ - lib/wheneverd/dsl/period_strategy/symbol_strategy.rb
69
76
  - lib/wheneverd/duration.rb
70
77
  - lib/wheneverd/entry.rb
71
78
  - lib/wheneverd/errors.rb
72
79
  - lib/wheneverd/interval.rb
73
80
  - lib/wheneverd/job/command.rb
74
81
  - lib/wheneverd/schedule.rb
82
+ - lib/wheneverd/service.rb
75
83
  - lib/wheneverd/systemd/analyze.rb
76
84
  - lib/wheneverd/systemd/calendar_spec.rb
77
85
  - lib/wheneverd/systemd/cron_parser.rb
86
+ - lib/wheneverd/systemd/cron_parser/dow_parser.rb
87
+ - lib/wheneverd/systemd/cron_parser/field_parser.rb
78
88
  - lib/wheneverd/systemd/errors.rb
79
89
  - lib/wheneverd/systemd/loginctl.rb
80
90
  - lib/wheneverd/systemd/renderer.rb
81
91
  - lib/wheneverd/systemd/systemctl.rb
82
92
  - lib/wheneverd/systemd/time_parser.rb
93
+ - lib/wheneverd/systemd/unit_content_builder.rb
83
94
  - lib/wheneverd/systemd/unit_deleter.rb
84
95
  - lib/wheneverd/systemd/unit_lister.rb
85
96
  - lib/wheneverd/systemd/unit_namer.rb
97
+ - lib/wheneverd/systemd/unit_path_utils.rb
86
98
  - lib/wheneverd/systemd/unit_writer.rb
99
+ - lib/wheneverd/trigger/base.rb
87
100
  - lib/wheneverd/trigger/boot.rb
88
101
  - lib/wheneverd/trigger/calendar.rb
89
102
  - lib/wheneverd/trigger/interval.rb
103
+ - lib/wheneverd/validation.rb
90
104
  - lib/wheneverd/version.rb
91
105
  - test/cli_activate_test.rb
92
106
  - test/cli_current_test.rb
@@ -142,7 +156,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
142
156
  - !ruby/object:Gem::Version
143
157
  version: '0'
144
158
  requirements: []
145
- rubygems_version: 4.0.4
159
+ rubygems_version: 4.0.11
146
160
  specification_version: 4
147
161
  summary: Wheneverd is to systemd timers what whenever is to cron.
148
162
  test_files: []