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
|
@@ -16,7 +16,7 @@ module Wheneverd
|
|
|
16
16
|
dest_dir = File.expand_path(unit_dir.to_s)
|
|
17
17
|
return [] unless Dir.exist?(dest_dir)
|
|
18
18
|
|
|
19
|
-
deleted = deletable_paths(dest_dir, basename_pattern(identifier))
|
|
19
|
+
deleted = deletable_paths(dest_dir, UnitPathUtils.basename_pattern(identifier))
|
|
20
20
|
deleted.each { |path| FileUtils.rm_f(path) unless dry_run }
|
|
21
21
|
deleted
|
|
22
22
|
end
|
|
@@ -27,38 +27,12 @@ module Wheneverd
|
|
|
27
27
|
|
|
28
28
|
path = File.join(dest_dir, basename)
|
|
29
29
|
next unless File.file?(path)
|
|
30
|
-
next unless generated_marker?(path)
|
|
30
|
+
next unless UnitPathUtils.generated_marker?(path)
|
|
31
31
|
|
|
32
32
|
path
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
35
|
private_class_method :deletable_paths
|
|
36
|
-
|
|
37
|
-
def self.basename_pattern(identifier)
|
|
38
|
-
id = sanitize_identifier(identifier)
|
|
39
|
-
/\Awheneverd-#{Regexp.escape(id)}-(?:[0-9a-f]{12}(?:-\d+)?|e\d+-j\d+)\.(service|timer)\z/
|
|
40
|
-
end
|
|
41
|
-
private_class_method :basename_pattern
|
|
42
|
-
|
|
43
|
-
def self.generated_marker?(path)
|
|
44
|
-
first_line = File.open(path, "r") { |f| f.gets.to_s }
|
|
45
|
-
first_line.start_with?(Wheneverd::Systemd::Renderer::MARKER_PREFIX)
|
|
46
|
-
end
|
|
47
|
-
private_class_method :generated_marker?
|
|
48
|
-
|
|
49
|
-
def self.sanitize_identifier(identifier)
|
|
50
|
-
raw = identifier.to_s.strip
|
|
51
|
-
raise InvalidIdentifierError, "identifier must not be empty" if raw.empty?
|
|
52
|
-
|
|
53
|
-
sanitized = raw.gsub(/[^A-Za-z0-9_-]/, "-").gsub(/-+/, "-").gsub(/\A-|-+\z/, "")
|
|
54
|
-
if sanitized.empty?
|
|
55
|
-
raise InvalidIdentifierError,
|
|
56
|
-
"identifier must include at least one alphanumeric character"
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
sanitized
|
|
60
|
-
end
|
|
61
|
-
private_class_method :sanitize_identifier
|
|
62
36
|
end
|
|
63
37
|
end
|
|
64
38
|
end
|
|
@@ -13,7 +13,7 @@ module Wheneverd
|
|
|
13
13
|
dest_dir = File.expand_path(unit_dir.to_s)
|
|
14
14
|
return [] unless Dir.exist?(dest_dir)
|
|
15
15
|
|
|
16
|
-
unit_paths(dest_dir, basename_pattern(identifier))
|
|
16
|
+
unit_paths(dest_dir, UnitPathUtils.basename_pattern(identifier))
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def self.unit_paths(dest_dir, pattern)
|
|
@@ -22,38 +22,12 @@ module Wheneverd
|
|
|
22
22
|
|
|
23
23
|
path = File.join(dest_dir, basename)
|
|
24
24
|
next unless File.file?(path)
|
|
25
|
-
next unless generated_marker?(path)
|
|
25
|
+
next unless UnitPathUtils.generated_marker?(path)
|
|
26
26
|
|
|
27
27
|
path
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
private_class_method :unit_paths
|
|
31
|
-
|
|
32
|
-
def self.basename_pattern(identifier)
|
|
33
|
-
id = sanitize_identifier(identifier)
|
|
34
|
-
/\Awheneverd-#{Regexp.escape(id)}-(?:[0-9a-f]{12}(?:-\d+)?|e\d+-j\d+)\.(service|timer)\z/
|
|
35
|
-
end
|
|
36
|
-
private_class_method :basename_pattern
|
|
37
|
-
|
|
38
|
-
def self.generated_marker?(path)
|
|
39
|
-
first_line = File.open(path, "r") { |f| f.gets.to_s }
|
|
40
|
-
first_line.start_with?(Wheneverd::Systemd::Renderer::MARKER_PREFIX)
|
|
41
|
-
end
|
|
42
|
-
private_class_method :generated_marker?
|
|
43
|
-
|
|
44
|
-
def self.sanitize_identifier(identifier)
|
|
45
|
-
raw = identifier.to_s.strip
|
|
46
|
-
raise InvalidIdentifierError, "identifier must not be empty" if raw.empty?
|
|
47
|
-
|
|
48
|
-
sanitized = raw.gsub(/[^A-Za-z0-9_-]/, "-").gsub(/-+/, "-").gsub(/\A-|-+\z/, "")
|
|
49
|
-
if sanitized.empty?
|
|
50
|
-
raise InvalidIdentifierError,
|
|
51
|
-
"identifier must include at least one alphanumeric character"
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
sanitized
|
|
55
|
-
end
|
|
56
|
-
private_class_method :sanitize_identifier
|
|
57
31
|
end
|
|
58
32
|
end
|
|
59
33
|
end
|
|
@@ -37,33 +37,24 @@ module Wheneverd
|
|
|
37
37
|
private_class_method :signature
|
|
38
38
|
|
|
39
39
|
def self.trigger_signature(trigger)
|
|
40
|
-
|
|
41
|
-
when Wheneverd::Trigger::Interval
|
|
42
|
-
"interval:#{trigger.seconds}"
|
|
43
|
-
when Wheneverd::Trigger::Boot
|
|
44
|
-
"boot:#{trigger.seconds}"
|
|
45
|
-
when Wheneverd::Trigger::Calendar
|
|
46
|
-
"calendar:#{trigger.on_calendar.sort.join('|')}"
|
|
47
|
-
else
|
|
40
|
+
unless trigger.respond_to?(:signature)
|
|
48
41
|
raise ArgumentError, "Unsupported trigger type: #{trigger.class}"
|
|
49
42
|
end
|
|
43
|
+
|
|
44
|
+
trigger.signature
|
|
50
45
|
end
|
|
51
46
|
private_class_method :trigger_signature
|
|
52
47
|
|
|
53
48
|
def self.job_signature(job)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
else
|
|
58
|
-
raise ArgumentError, "Unsupported job type: #{job.class}"
|
|
59
|
-
end
|
|
49
|
+
raise ArgumentError, "Unsupported job type: #{job.class}" unless job.respond_to?(:signature)
|
|
50
|
+
|
|
51
|
+
job.signature
|
|
60
52
|
end
|
|
61
53
|
private_class_method :job_signature
|
|
62
54
|
|
|
63
55
|
def self.stable_id_for(signature)
|
|
64
56
|
Digest::SHA256.hexdigest(signature).slice(0, 12)
|
|
65
57
|
end
|
|
66
|
-
private_class_method :stable_id_for
|
|
67
58
|
end
|
|
68
59
|
end
|
|
69
60
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
module Systemd
|
|
5
|
+
# Shared utilities for unit file path operations.
|
|
6
|
+
#
|
|
7
|
+
# These methods are used by {UnitWriter}, {UnitDeleter}, {UnitLister}, and {Renderer}
|
|
8
|
+
# to ensure consistent identifier sanitization, path pattern matching, and marker detection.
|
|
9
|
+
module UnitPathUtils
|
|
10
|
+
# Sanitizes an identifier for use in unit file names.
|
|
11
|
+
#
|
|
12
|
+
# Invalid characters are replaced with `-`, and consecutive/leading/trailing dashes
|
|
13
|
+
# are collapsed or removed.
|
|
14
|
+
#
|
|
15
|
+
# @param identifier [String]
|
|
16
|
+
# @return [String]
|
|
17
|
+
# @raise [InvalidIdentifierError] if the identifier is empty or contains no alphanumeric chars
|
|
18
|
+
def self.sanitize_identifier(identifier)
|
|
19
|
+
raw = identifier.to_s.strip
|
|
20
|
+
raise InvalidIdentifierError, "identifier must not be empty" if raw.empty?
|
|
21
|
+
|
|
22
|
+
sanitized = raw.gsub(/[^A-Za-z0-9_-]/, "-").gsub(/-+/, "-").gsub(/\A-|-+\z/, "")
|
|
23
|
+
if sanitized.empty?
|
|
24
|
+
raise InvalidIdentifierError,
|
|
25
|
+
"identifier must include at least one alphanumeric character"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
sanitized
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns a regex pattern matching unit basenames for the given identifier.
|
|
32
|
+
#
|
|
33
|
+
# Matches both new-style (SHA-based) and legacy (eN-jN) unit names.
|
|
34
|
+
#
|
|
35
|
+
# @param identifier [String]
|
|
36
|
+
# @return [Regexp]
|
|
37
|
+
def self.basename_pattern(identifier)
|
|
38
|
+
id = sanitize_identifier(identifier)
|
|
39
|
+
/\Awheneverd-#{Regexp.escape(id)}-(?:[0-9a-f]{12}(?:-\d+)?|e\d+-j\d+)\.(service|timer)\z/
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Checks if a unit file was generated by wheneverd.
|
|
43
|
+
#
|
|
44
|
+
# Reads the first line of the file and checks for the marker prefix.
|
|
45
|
+
#
|
|
46
|
+
# @param path [String] path to the unit file
|
|
47
|
+
# @return [Boolean]
|
|
48
|
+
def self.generated_marker?(path)
|
|
49
|
+
first_line = File.open(path, "r") { |f| f.gets.to_s }
|
|
50
|
+
first_line.start_with?(Wheneverd::Systemd::Renderer::MARKER_PREFIX)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -67,7 +67,7 @@ module Wheneverd
|
|
|
67
67
|
private_class_method :prune_stale_units
|
|
68
68
|
|
|
69
69
|
def self.stale_unit_paths(dest_dir, identifier:, keep:)
|
|
70
|
-
pattern = basename_pattern(identifier)
|
|
70
|
+
pattern = UnitPathUtils.basename_pattern(identifier)
|
|
71
71
|
Dir.children(dest_dir).filter_map do |basename|
|
|
72
72
|
next if keep.key?(basename)
|
|
73
73
|
|
|
@@ -83,36 +83,10 @@ module Wheneverd
|
|
|
83
83
|
return false unless pattern.match?(basename)
|
|
84
84
|
return false unless File.file?(path)
|
|
85
85
|
|
|
86
|
-
generated_marker?(path)
|
|
86
|
+
UnitPathUtils.generated_marker?(path)
|
|
87
87
|
end
|
|
88
88
|
private_class_method :stale_unit_path?
|
|
89
89
|
|
|
90
|
-
def self.basename_pattern(identifier)
|
|
91
|
-
id = sanitize_identifier(identifier)
|
|
92
|
-
/\Awheneverd-#{Regexp.escape(id)}-(?:[0-9a-f]{12}(?:-\d+)?|e\d+-j\d+)\.(service|timer)\z/
|
|
93
|
-
end
|
|
94
|
-
private_class_method :basename_pattern
|
|
95
|
-
|
|
96
|
-
def self.generated_marker?(path)
|
|
97
|
-
first_line = File.open(path, "r") { |f| f.gets.to_s }
|
|
98
|
-
first_line.start_with?(Wheneverd::Systemd::Renderer::MARKER_PREFIX)
|
|
99
|
-
end
|
|
100
|
-
private_class_method :generated_marker?
|
|
101
|
-
|
|
102
|
-
def self.sanitize_identifier(identifier)
|
|
103
|
-
raw = identifier.to_s.strip
|
|
104
|
-
raise InvalidIdentifierError, "identifier must not be empty" if raw.empty?
|
|
105
|
-
|
|
106
|
-
sanitized = raw.gsub(/[^A-Za-z0-9_-]/, "-").gsub(/-+/, "-").gsub(/\A-|-+\z/, "")
|
|
107
|
-
if sanitized.empty?
|
|
108
|
-
raise InvalidIdentifierError,
|
|
109
|
-
"identifier must include at least one alphanumeric character"
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
sanitized
|
|
113
|
-
end
|
|
114
|
-
private_class_method :sanitize_identifier
|
|
115
|
-
|
|
116
90
|
def self.atomic_write(dest_path, contents, dir:)
|
|
117
91
|
basename = File.basename(dest_path)
|
|
118
92
|
tmp = Tempfile.new([".#{basename}.", ".tmp"], dir)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
module Trigger
|
|
5
|
+
# Base module for trigger types.
|
|
6
|
+
#
|
|
7
|
+
# All trigger types must implement:
|
|
8
|
+
# - `#systemd_timer_lines` - returns Array<String> of systemd [Timer] lines
|
|
9
|
+
# - `#signature` - returns a String signature for stable unit naming
|
|
10
|
+
module Base
|
|
11
|
+
# @return [Array<String>] systemd `[Timer]` lines for this trigger
|
|
12
|
+
def systemd_timer_lines
|
|
13
|
+
raise NotImplementedError, "#{self.class} must implement #systemd_timer_lines"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [String] stable signature for unit naming
|
|
17
|
+
def signature
|
|
18
|
+
raise NotImplementedError, "#{self.class} must implement #signature"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -4,23 +4,25 @@ module Wheneverd
|
|
|
4
4
|
module Trigger
|
|
5
5
|
# A boot trigger, rendered as `OnBootSec=`.
|
|
6
6
|
class Boot
|
|
7
|
+
include Base
|
|
8
|
+
|
|
7
9
|
# @return [Integer]
|
|
8
10
|
attr_reader :seconds
|
|
9
11
|
|
|
10
12
|
# @param seconds [Integer] seconds after boot (must be positive)
|
|
11
13
|
def initialize(seconds:)
|
|
12
|
-
|
|
13
|
-
raise ArgumentError, "Boot seconds must be an Integer (got #{seconds.class})"
|
|
14
|
-
end
|
|
15
|
-
raise ArgumentError, "Boot seconds must be positive (got #{seconds})" if seconds <= 0
|
|
16
|
-
|
|
17
|
-
@seconds = seconds
|
|
14
|
+
@seconds = Validation.positive_integer(seconds, name: "Boot seconds")
|
|
18
15
|
end
|
|
19
16
|
|
|
20
17
|
# @return [Array<String>] systemd `[Timer]` lines for this trigger
|
|
21
18
|
def systemd_timer_lines
|
|
22
19
|
["OnBootSec=#{seconds}"]
|
|
23
20
|
end
|
|
21
|
+
|
|
22
|
+
# @return [String] stable signature for unit naming
|
|
23
|
+
def signature
|
|
24
|
+
"boot:#{seconds}"
|
|
25
|
+
end
|
|
24
26
|
end
|
|
25
27
|
end
|
|
26
28
|
end
|
|
@@ -4,6 +4,8 @@ module Wheneverd
|
|
|
4
4
|
module Trigger
|
|
5
5
|
# A calendar trigger, rendered as one or more `OnCalendar=` lines.
|
|
6
6
|
class Calendar
|
|
7
|
+
include Base
|
|
8
|
+
|
|
7
9
|
# @return [Array<String>] calendar specs (already in `systemd` OnCalendar format)
|
|
8
10
|
attr_reader :on_calendar
|
|
9
11
|
|
|
@@ -21,6 +23,11 @@ module Wheneverd
|
|
|
21
23
|
def systemd_timer_lines
|
|
22
24
|
on_calendar.map { |spec| "OnCalendar=#{spec}" }
|
|
23
25
|
end
|
|
26
|
+
|
|
27
|
+
# @return [String] stable signature for unit naming
|
|
28
|
+
def signature
|
|
29
|
+
"calendar:#{on_calendar.sort.join('|')}"
|
|
30
|
+
end
|
|
24
31
|
end
|
|
25
32
|
end
|
|
26
33
|
end
|
|
@@ -8,23 +8,25 @@ module Wheneverd
|
|
|
8
8
|
# - `OnActiveSec=` to schedule the first run relative to timer activation.
|
|
9
9
|
# - `OnUnitActiveSec=` to schedule subsequent runs relative to the last run.
|
|
10
10
|
class Interval
|
|
11
|
+
include Base
|
|
12
|
+
|
|
11
13
|
# @return [Integer]
|
|
12
14
|
attr_reader :seconds
|
|
13
15
|
|
|
14
16
|
# @param seconds [Integer] seconds between runs (must be positive)
|
|
15
17
|
def initialize(seconds:)
|
|
16
|
-
|
|
17
|
-
raise ArgumentError, "Interval seconds must be an Integer (got #{seconds.class})"
|
|
18
|
-
end
|
|
19
|
-
raise ArgumentError, "Interval seconds must be positive (got #{seconds})" if seconds <= 0
|
|
20
|
-
|
|
21
|
-
@seconds = seconds
|
|
18
|
+
@seconds = Validation.positive_integer(seconds, name: "Interval seconds")
|
|
22
19
|
end
|
|
23
20
|
|
|
24
21
|
# @return [Array<String>] systemd `[Timer]` lines for this trigger
|
|
25
22
|
def systemd_timer_lines
|
|
26
23
|
["OnActiveSec=#{seconds}", "OnUnitActiveSec=#{seconds}"]
|
|
27
24
|
end
|
|
25
|
+
|
|
26
|
+
# @return [String] stable signature for unit naming
|
|
27
|
+
def signature
|
|
28
|
+
"interval:#{seconds}"
|
|
29
|
+
end
|
|
28
30
|
end
|
|
29
31
|
end
|
|
30
32
|
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
# Common validation utilities for consistent error handling across the codebase.
|
|
5
|
+
#
|
|
6
|
+
# These validators follow a consistent pattern:
|
|
7
|
+
# - Return the validated value if valid
|
|
8
|
+
# - Raise an appropriate error with a descriptive message if invalid
|
|
9
|
+
#
|
|
10
|
+
# @example Type validation
|
|
11
|
+
# Validation.type(value, Integer, name: "seconds")
|
|
12
|
+
# # => value or raises ArgumentError
|
|
13
|
+
#
|
|
14
|
+
# @example Positive integer validation
|
|
15
|
+
# Validation.positive_integer(value, name: "seconds")
|
|
16
|
+
# # => value or raises ArgumentError
|
|
17
|
+
module Validation
|
|
18
|
+
# Validate that a value is of the expected type.
|
|
19
|
+
#
|
|
20
|
+
# @param value [Object] the value to validate
|
|
21
|
+
# @param expected_type [Class, Module] the expected type
|
|
22
|
+
# @param name [String] the parameter name for error messages
|
|
23
|
+
# @return [Object] the validated value
|
|
24
|
+
# @raise [ArgumentError] if the value is not of the expected type
|
|
25
|
+
def self.type(value, expected_type, name:)
|
|
26
|
+
return value if value.is_a?(expected_type)
|
|
27
|
+
|
|
28
|
+
raise ArgumentError,
|
|
29
|
+
"#{name} must be #{expected_type_name(expected_type)} (got #{value.class})"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Validate that a value is a positive integer.
|
|
33
|
+
#
|
|
34
|
+
# @param value [Object] the value to validate
|
|
35
|
+
# @param name [String] the parameter name for error messages
|
|
36
|
+
# @return [Integer] the validated value
|
|
37
|
+
# @raise [ArgumentError] if the value is not a positive integer
|
|
38
|
+
def self.positive_integer(value, name:)
|
|
39
|
+
type(value, Integer, name: name)
|
|
40
|
+
return value if value.positive?
|
|
41
|
+
|
|
42
|
+
raise ArgumentError, "#{name} must be positive (got #{value})"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Validate that a string is non-empty after stripping whitespace.
|
|
46
|
+
#
|
|
47
|
+
# @param value [String] the value to validate
|
|
48
|
+
# @param name [String] the parameter name for error messages
|
|
49
|
+
# @return [String] the stripped value
|
|
50
|
+
# @raise [ArgumentError] if the value is empty after stripping
|
|
51
|
+
def self.non_empty_string(value, name:)
|
|
52
|
+
stripped = value.to_s.strip
|
|
53
|
+
return stripped unless stripped.empty?
|
|
54
|
+
|
|
55
|
+
raise ArgumentError, "#{name} must not be empty"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Validate that an array is non-empty.
|
|
59
|
+
#
|
|
60
|
+
# @param value [Array] the value to validate
|
|
61
|
+
# @param name [String] the parameter name for error messages
|
|
62
|
+
# @return [Array] the validated value
|
|
63
|
+
# @raise [ArgumentError] if the array is empty
|
|
64
|
+
def self.non_empty_array(value, name:)
|
|
65
|
+
type(value, Array, name: name)
|
|
66
|
+
return value unless value.empty?
|
|
67
|
+
|
|
68
|
+
raise ArgumentError, "#{name} must not be empty"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Validate that a value is within a range.
|
|
72
|
+
#
|
|
73
|
+
# @param value [Comparable] the value to validate
|
|
74
|
+
# @param range [Range] the valid range
|
|
75
|
+
# @param name [String] the parameter name for error messages
|
|
76
|
+
# @return [Comparable] the validated value
|
|
77
|
+
# @raise [ArgumentError] if the value is outside the range
|
|
78
|
+
def self.in_range(value, range, name:)
|
|
79
|
+
return value if range.cover?(value)
|
|
80
|
+
|
|
81
|
+
raise ArgumentError, "#{name} must be in #{range} (got #{value})"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.expected_type_name(expected_type)
|
|
85
|
+
"a #{expected_type}"
|
|
86
|
+
end
|
|
87
|
+
private_class_method :expected_type_name
|
|
88
|
+
end
|
|
89
|
+
end
|
data/lib/wheneverd/version.rb
CHANGED
data/lib/wheneverd.rb
CHANGED
|
@@ -11,14 +11,16 @@ module Wheneverd
|
|
|
11
11
|
# - {Wheneverd::DSL::Loader} for evaluating `config/schedule.rb`
|
|
12
12
|
# - {Wheneverd::Systemd::Renderer} for generating unit contents
|
|
13
13
|
# - {Wheneverd::CLI} for the command-line interface
|
|
14
|
-
class Error < StandardError; end
|
|
15
14
|
end
|
|
16
15
|
|
|
17
16
|
require_relative "wheneverd/errors"
|
|
17
|
+
require_relative "wheneverd/validation"
|
|
18
18
|
require_relative "wheneverd/duration"
|
|
19
19
|
require_relative "wheneverd/interval"
|
|
20
20
|
require_relative "wheneverd/core_ext/numeric_duration"
|
|
21
21
|
require_relative "wheneverd/job/command"
|
|
22
|
+
require_relative "wheneverd/service"
|
|
23
|
+
require_relative "wheneverd/trigger/base"
|
|
22
24
|
require_relative "wheneverd/trigger/interval"
|
|
23
25
|
require_relative "wheneverd/trigger/calendar"
|
|
24
26
|
require_relative "wheneverd/trigger/boot"
|
|
@@ -30,10 +32,12 @@ require_relative "wheneverd/dsl/period_parser"
|
|
|
30
32
|
require_relative "wheneverd/dsl/context"
|
|
31
33
|
require_relative "wheneverd/dsl/loader"
|
|
32
34
|
require_relative "wheneverd/systemd/errors"
|
|
35
|
+
require_relative "wheneverd/systemd/unit_path_utils"
|
|
33
36
|
require_relative "wheneverd/systemd/time_parser"
|
|
34
37
|
require_relative "wheneverd/systemd/cron_parser"
|
|
35
38
|
require_relative "wheneverd/systemd/calendar_spec"
|
|
36
39
|
require_relative "wheneverd/systemd/unit_namer"
|
|
40
|
+
require_relative "wheneverd/systemd/unit_content_builder"
|
|
37
41
|
require_relative "wheneverd/systemd/renderer"
|
|
38
42
|
require_relative "wheneverd/systemd/analyze"
|
|
39
43
|
require_relative "wheneverd/systemd/systemctl"
|
data/test/cli_activate_test.rb
CHANGED
|
@@ -37,6 +37,33 @@ class CLIActivateSuccessTest < Minitest::Test
|
|
|
37
37
|
includes: expected_timer_basenames)
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
|
+
|
|
41
|
+
def test_runs_enable_now_for_standalone_services
|
|
42
|
+
with_service_project_dir do
|
|
43
|
+
status, out, err, calls = run_activate_with_capture3_stub
|
|
44
|
+
assert_cli_success(status, err)
|
|
45
|
+
service = expected_standalone_service_basenames.fetch(0)
|
|
46
|
+
assert_includes out, service
|
|
47
|
+
assert_systemctl_call_starts_with(calls, 1, SYSTEMCTL_USER_PREFIX + ["enable", "--now"],
|
|
48
|
+
includes: service)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def with_service_project_dir
|
|
55
|
+
with_project_dir do
|
|
56
|
+
FileUtils.mkdir_p("config")
|
|
57
|
+
File.write("config/schedule.rb", <<~RUBY)
|
|
58
|
+
service "worker", shell: "bin/worker"
|
|
59
|
+
RUBY
|
|
60
|
+
yield
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def expected_standalone_service_basenames
|
|
65
|
+
expected_units.select { |unit| unit.activation == :service }.map(&:path_basename)
|
|
66
|
+
end
|
|
40
67
|
end
|
|
41
68
|
|
|
42
69
|
class CLIActivateScheduleMissingTest < Minitest::Test
|
data/test/cli_reload_test.rb
CHANGED
|
@@ -41,6 +41,29 @@ class CLIReloadSuccessTest < Minitest::Test
|
|
|
41
41
|
includes: expected_timer_basenames)
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
|
+
|
|
45
|
+
def test_restarts_standalone_services
|
|
46
|
+
with_service_project_dir do |project_dir|
|
|
47
|
+
unit_dir = File.join(project_dir, "tmp_units")
|
|
48
|
+
status, _out, err, calls = run_reload_with_capture3_stub(unit_dir: unit_dir)
|
|
49
|
+
assert_cli_success(status, err)
|
|
50
|
+
service = expected_units.find { |unit| unit.activation == :service }.path_basename
|
|
51
|
+
assert_systemctl_call_starts_with(calls, 1, SYSTEMCTL_USER_PREFIX + ["restart"],
|
|
52
|
+
includes: service)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def with_service_project_dir
|
|
59
|
+
with_project_dir do |project_dir|
|
|
60
|
+
FileUtils.mkdir_p("config")
|
|
61
|
+
File.write("config/schedule.rb", <<~RUBY)
|
|
62
|
+
service "worker", shell: "bin/worker"
|
|
63
|
+
RUBY
|
|
64
|
+
yield project_dir
|
|
65
|
+
end
|
|
66
|
+
end
|
|
44
67
|
end
|
|
45
68
|
|
|
46
69
|
class CLIReloadScheduleMissingTest < Minitest::Test
|
data/test/cli_status_test.rb
CHANGED
|
@@ -6,11 +6,11 @@ require_relative "support/cli_test_helpers"
|
|
|
6
6
|
class CLIStatusTest < Minitest::Test
|
|
7
7
|
include CLITestHelpers
|
|
8
8
|
|
|
9
|
-
def
|
|
9
|
+
def test_runs_list_timers_and_status_for_each_installed_unit
|
|
10
10
|
with_installed_units do |unit_dir|
|
|
11
11
|
status, _out, err, calls = run_status(unit_dir)
|
|
12
12
|
assert_cli_success(status, err)
|
|
13
|
-
assert_status_calls(calls, expected_timer_units)
|
|
13
|
+
assert_status_calls(calls, expected_timer_units, expected_service_units)
|
|
14
14
|
end
|
|
15
15
|
end
|
|
16
16
|
|
|
@@ -39,6 +39,9 @@ class CLIStatusTest < Minitest::Test
|
|
|
39
39
|
|
|
40
40
|
def with_installed_units
|
|
41
41
|
with_inited_project_dir do |project_dir|
|
|
42
|
+
File.open(File.join(project_dir, "config", "schedule.rb"), "a") do |file|
|
|
43
|
+
file.puts 'service "worker", shell: "bin/worker"'
|
|
44
|
+
end
|
|
42
45
|
unit_dir = File.join(project_dir, "tmp_units")
|
|
43
46
|
assert_equal 0, run_cli(["write", "--identifier", "demo", "--unit-dir", unit_dir]).first
|
|
44
47
|
yield unit_dir
|
|
@@ -53,9 +56,16 @@ class CLIStatusTest < Minitest::Test
|
|
|
53
56
|
expected_timer_basenames(identifier: "demo").sort
|
|
54
57
|
end
|
|
55
58
|
|
|
56
|
-
def
|
|
59
|
+
def expected_service_units
|
|
60
|
+
expected_units(identifier: "demo")
|
|
61
|
+
.select { |unit| unit.activation == :service }
|
|
62
|
+
.map(&:path_basename)
|
|
63
|
+
.sort
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def assert_status_calls(calls, expected_timers, expected_services)
|
|
57
67
|
assert_list_timers_call(calls, expected_timers)
|
|
58
|
-
assert_status_unit_calls(calls, expected_timers)
|
|
68
|
+
assert_status_unit_calls(calls, (expected_timers + expected_services).sort)
|
|
59
69
|
end
|
|
60
70
|
|
|
61
71
|
def assert_list_timers_call(calls, expected_timers)
|