wheneverd 0.2.1 → 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 +16 -0
- data/FEATURE_SUMMARY.md +10 -1
- data/Gemfile.lock +2 -2
- data/README.md +70 -5
- data/lib/wheneverd/cli/diff.rb +98 -0
- data/lib/wheneverd/cli/init.rb +5 -1
- data/lib/wheneverd/cli/status.rb +37 -0
- data/lib/wheneverd/cli/validate.rb +50 -0
- data/lib/wheneverd/cli.rb +6 -0
- data/lib/wheneverd/dsl/context.rb +51 -8
- 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/job/command.rb +88 -8
- data/lib/wheneverd/systemd/analyze.rb +56 -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/errors.rb +3 -0
- 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 +5 -1
- data/test/cli_diff_test.rb +118 -0
- data/test/cli_init_test.rb +49 -0
- data/test/cli_status_test.rb +76 -0
- data/test/cli_validate_test.rb +81 -0
- data/test/domain_model_test.rb +106 -1
- data/test/dsl_context_shell_test.rb +23 -0
- data/test/dsl_loader_test.rb +23 -0
- data/test/job_command_test.rb +29 -0
- data/test/systemd_analyze_test.rb +55 -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 +6 -0
- metadata +25 -2
|
@@ -37,26 +37,18 @@ 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
|
|
|
@@ -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,15 @@ 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/trigger/base"
|
|
22
23
|
require_relative "wheneverd/trigger/interval"
|
|
23
24
|
require_relative "wheneverd/trigger/calendar"
|
|
24
25
|
require_relative "wheneverd/trigger/boot"
|
|
@@ -30,11 +31,14 @@ require_relative "wheneverd/dsl/period_parser"
|
|
|
30
31
|
require_relative "wheneverd/dsl/context"
|
|
31
32
|
require_relative "wheneverd/dsl/loader"
|
|
32
33
|
require_relative "wheneverd/systemd/errors"
|
|
34
|
+
require_relative "wheneverd/systemd/unit_path_utils"
|
|
33
35
|
require_relative "wheneverd/systemd/time_parser"
|
|
34
36
|
require_relative "wheneverd/systemd/cron_parser"
|
|
35
37
|
require_relative "wheneverd/systemd/calendar_spec"
|
|
36
38
|
require_relative "wheneverd/systemd/unit_namer"
|
|
39
|
+
require_relative "wheneverd/systemd/unit_content_builder"
|
|
37
40
|
require_relative "wheneverd/systemd/renderer"
|
|
41
|
+
require_relative "wheneverd/systemd/analyze"
|
|
38
42
|
require_relative "wheneverd/systemd/systemctl"
|
|
39
43
|
require_relative "wheneverd/systemd/loginctl"
|
|
40
44
|
require_relative "wheneverd/systemd/unit_writer"
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
require_relative "support/cli_test_helpers"
|
|
5
|
+
|
|
6
|
+
class CLIDiffTest < Minitest::Test
|
|
7
|
+
include CLITestHelpers
|
|
8
|
+
|
|
9
|
+
def test_exits_one_and_shows_added_diff_when_units_are_not_installed
|
|
10
|
+
with_inited_unit_dir("missing_units") do |unit_dir|
|
|
11
|
+
timer = first_timer
|
|
12
|
+
status, out, err = run_diff(unit_dir)
|
|
13
|
+
assert_diff_added(status, out, err, unit_dir, timer)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def test_exits_zero_when_no_differences
|
|
18
|
+
with_installed_units do |unit_dir|
|
|
19
|
+
status, out, err = run_diff(unit_dir)
|
|
20
|
+
assert_equal 0, status
|
|
21
|
+
assert_equal "", err
|
|
22
|
+
assert_equal "", out
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def test_exits_one_and_shows_removed_lines_when_installed_unit_was_modified
|
|
27
|
+
with_installed_units do |unit_dir|
|
|
28
|
+
timer = first_timer
|
|
29
|
+
File.open(File.join(unit_dir, timer), "a") { |f| f.puts "# local edit" }
|
|
30
|
+
status, out, err = run_diff(unit_dir)
|
|
31
|
+
assert_diff_contains(status, out, err, timer, "-# local edit")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def test_exits_one_and_shows_added_lines_when_installed_unit_is_missing_lines
|
|
36
|
+
with_installed_units do |unit_dir|
|
|
37
|
+
timer = first_timer
|
|
38
|
+
remove_exact_line(File.join(unit_dir, timer), "Persistent=true\n")
|
|
39
|
+
status, out, err = run_diff(unit_dir)
|
|
40
|
+
assert_diff_contains(status, out, err, timer, "+Persistent=true")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def test_exits_one_and_shows_diff_for_stale_units_on_disk
|
|
45
|
+
with_installed_units do |unit_dir|
|
|
46
|
+
stale_timer = "wheneverd-demo-000000000000.timer"
|
|
47
|
+
stale_path = write_stale_timer(unit_dir, stale_timer)
|
|
48
|
+
status, out, err = run_diff(unit_dir)
|
|
49
|
+
assert_diff_removed(status, out, err, stale_timer, stale_path)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_returns_two_on_error
|
|
54
|
+
with_project_dir do
|
|
55
|
+
status, out, err = run_cli(["diff", "--schedule", "missing.rb"])
|
|
56
|
+
assert_equal 2, status
|
|
57
|
+
assert_equal "", out
|
|
58
|
+
assert_includes err, "Schedule file not found"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def with_inited_unit_dir(name)
|
|
65
|
+
with_inited_project_dir do |project_dir|
|
|
66
|
+
yield File.join(project_dir, name)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def with_installed_units
|
|
71
|
+
with_inited_unit_dir("tmp_units") do |unit_dir|
|
|
72
|
+
assert_equal 0, run_cli(["write", "--identifier", "demo", "--unit-dir", unit_dir]).first
|
|
73
|
+
yield unit_dir
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def run_diff(unit_dir)
|
|
78
|
+
run_cli(["diff", "--identifier", "demo", "--unit-dir", unit_dir])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def first_timer
|
|
82
|
+
expected_timer_basenames(identifier: "demo").fetch(0)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def remove_exact_line(path, line)
|
|
86
|
+
File.write(path, File.read(path).lines.reject { |l| l == line }.join)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def write_stale_timer(unit_dir, basename)
|
|
90
|
+
path = File.join(unit_dir, basename)
|
|
91
|
+
File.write(path, "#{Wheneverd::Systemd::Renderer::MARKER_PREFIX} test\n# stale\n")
|
|
92
|
+
path
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def assert_diff_added(status, out, err, unit_dir, timer)
|
|
96
|
+
assert_equal 1, status
|
|
97
|
+
assert_equal "", err
|
|
98
|
+
assert_includes out, "diff --wheneverd #{timer}"
|
|
99
|
+
assert_includes out, "--- /dev/null"
|
|
100
|
+
assert_includes out, "+++ #{File.join(File.expand_path(unit_dir), timer)}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def assert_diff_contains(status, out, err, timer, expected_line)
|
|
104
|
+
assert_equal 1, status
|
|
105
|
+
assert_equal "", err
|
|
106
|
+
assert_includes out, "diff --wheneverd #{timer}"
|
|
107
|
+
assert_includes out, expected_line
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def assert_diff_removed(status, out, err, timer, path)
|
|
111
|
+
assert_equal 1, status
|
|
112
|
+
assert_equal "", err
|
|
113
|
+
assert_includes out, "diff --wheneverd #{timer}"
|
|
114
|
+
assert_includes out, "--- #{path}"
|
|
115
|
+
assert_includes out, "+++ /dev/null"
|
|
116
|
+
assert_includes out, "-# stale"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
require_relative "support/cli_test_helpers"
|
|
5
|
+
|
|
6
|
+
class CLIInitTest < Minitest::Test
|
|
7
|
+
include CLITestHelpers
|
|
8
|
+
|
|
9
|
+
def test_writes_template_with_shell_and_argv_examples
|
|
10
|
+
with_project_dir do |project_dir|
|
|
11
|
+
status, out, err = run_cli(["init"])
|
|
12
|
+
assert_equal 0, status
|
|
13
|
+
assert_equal "", err
|
|
14
|
+
assert_includes out, "Wrote schedule template to"
|
|
15
|
+
|
|
16
|
+
schedule_path = File.join(project_dir, "config", "schedule.rb")
|
|
17
|
+
schedule = File.read(schedule_path)
|
|
18
|
+
assert_includes schedule, "command [\"echo\", \"hello world\"]"
|
|
19
|
+
assert_includes schedule, "shell \"echo hello | sed -e s/hello/hi/\""
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def test_refuses_to_overwrite_without_force
|
|
24
|
+
with_project_dir do
|
|
25
|
+
assert_equal 0, run_cli(["init"]).first
|
|
26
|
+
|
|
27
|
+
status, out, err = run_cli(["init"])
|
|
28
|
+
assert_equal 1, status
|
|
29
|
+
assert_equal "", out
|
|
30
|
+
assert_includes err, "already exists"
|
|
31
|
+
assert_includes err, "--force"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def test_overwrites_with_force
|
|
36
|
+
with_project_dir do |project_dir|
|
|
37
|
+
assert_equal 0, run_cli(["init"]).first
|
|
38
|
+
|
|
39
|
+
schedule_path = File.join(project_dir, "config", "schedule.rb")
|
|
40
|
+
File.write(schedule_path, "# custom\n")
|
|
41
|
+
|
|
42
|
+
status, out, err = run_cli(["init", "--force"])
|
|
43
|
+
assert_equal 0, status
|
|
44
|
+
assert_equal "", err
|
|
45
|
+
assert_includes out, "Overwrote schedule template to"
|
|
46
|
+
assert_includes File.read(schedule_path), "Supported `every` period forms:"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
require_relative "support/cli_test_helpers"
|
|
5
|
+
|
|
6
|
+
class CLIStatusTest < Minitest::Test
|
|
7
|
+
include CLITestHelpers
|
|
8
|
+
|
|
9
|
+
def test_runs_list_timers_and_status_for_each_installed_timer
|
|
10
|
+
with_installed_units do |unit_dir|
|
|
11
|
+
status, _out, err, calls = run_status(unit_dir)
|
|
12
|
+
assert_cli_success(status, err)
|
|
13
|
+
assert_status_calls(calls, expected_timer_units)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def test_returns_nonzero_when_systemctl_fails
|
|
18
|
+
with_installed_units do |unit_dir|
|
|
19
|
+
status, _out, err, calls = run_status(unit_dir, exitstatus: 1, stderr: "boom\n")
|
|
20
|
+
assert_equal 1, status
|
|
21
|
+
assert_includes err, "systemctl failed"
|
|
22
|
+
assert_includes err, "boom"
|
|
23
|
+
assert_includes calls.fetch(0).fetch(0), "list-timers"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def test_exits_zero_and_does_not_call_systemctl_when_no_timers_installed
|
|
28
|
+
with_project_dir do |project_dir|
|
|
29
|
+
unit_dir = File.join(project_dir, "empty_units")
|
|
30
|
+
FileUtils.mkdir_p(unit_dir)
|
|
31
|
+
status, out, err, calls = run_status(unit_dir)
|
|
32
|
+
assert_cli_success(status, err)
|
|
33
|
+
assert_equal "", out
|
|
34
|
+
assert_equal [], calls
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def with_installed_units
|
|
41
|
+
with_inited_project_dir do |project_dir|
|
|
42
|
+
unit_dir = File.join(project_dir, "tmp_units")
|
|
43
|
+
assert_equal 0, run_cli(["write", "--identifier", "demo", "--unit-dir", unit_dir]).first
|
|
44
|
+
yield unit_dir
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def run_status(unit_dir, **kwargs)
|
|
49
|
+
run_cli_with_capture3_stub(["status", "--identifier", "demo", "--unit-dir", unit_dir], **kwargs)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def expected_timer_units
|
|
53
|
+
expected_timer_basenames(identifier: "demo").sort
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def assert_status_calls(calls, expected_timers)
|
|
57
|
+
assert_list_timers_call(calls, expected_timers)
|
|
58
|
+
assert_status_unit_calls(calls, expected_timers)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def assert_list_timers_call(calls, expected_timers)
|
|
62
|
+
assert_systemctl_call_starts_with(
|
|
63
|
+
calls,
|
|
64
|
+
0,
|
|
65
|
+
SYSTEMCTL_USER_PREFIX + ["list-timers", "--all"],
|
|
66
|
+
includes: expected_timers
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def assert_status_unit_calls(calls, expected_timers)
|
|
71
|
+
status_calls = calls.drop(1).map(&:first)
|
|
72
|
+
assert_equal expected_timers.length, status_calls.length
|
|
73
|
+
assert_equal expected_timers, status_calls.map(&:last).sort
|
|
74
|
+
status_calls.each { |args| assert_equal SYSTEMCTL_USER_PREFIX + ["status", args.last], args }
|
|
75
|
+
end
|
|
76
|
+
end
|