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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/FEATURE_SUMMARY.md +10 -1
  4. data/Gemfile.lock +2 -2
  5. data/README.md +70 -5
  6. data/lib/wheneverd/cli/diff.rb +98 -0
  7. data/lib/wheneverd/cli/init.rb +5 -1
  8. data/lib/wheneverd/cli/status.rb +37 -0
  9. data/lib/wheneverd/cli/validate.rb +50 -0
  10. data/lib/wheneverd/cli.rb +6 -0
  11. data/lib/wheneverd/dsl/context.rb +51 -8
  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/job/command.rb +88 -8
  23. data/lib/wheneverd/systemd/analyze.rb +56 -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/errors.rb +3 -0
  28. data/lib/wheneverd/systemd/renderer.rb +6 -64
  29. data/lib/wheneverd/systemd/unit_content_builder.rb +76 -0
  30. data/lib/wheneverd/systemd/unit_deleter.rb +2 -28
  31. data/lib/wheneverd/systemd/unit_lister.rb +2 -28
  32. data/lib/wheneverd/systemd/unit_namer.rb +6 -14
  33. data/lib/wheneverd/systemd/unit_path_utils.rb +54 -0
  34. data/lib/wheneverd/systemd/unit_writer.rb +2 -28
  35. data/lib/wheneverd/trigger/base.rb +22 -0
  36. data/lib/wheneverd/trigger/boot.rb +8 -6
  37. data/lib/wheneverd/trigger/calendar.rb +7 -0
  38. data/lib/wheneverd/trigger/interval.rb +8 -6
  39. data/lib/wheneverd/validation.rb +89 -0
  40. data/lib/wheneverd/version.rb +1 -1
  41. data/lib/wheneverd.rb +5 -1
  42. data/test/cli_diff_test.rb +118 -0
  43. data/test/cli_init_test.rb +49 -0
  44. data/test/cli_status_test.rb +76 -0
  45. data/test/cli_validate_test.rb +81 -0
  46. data/test/domain_model_test.rb +106 -1
  47. data/test/dsl_context_shell_test.rb +23 -0
  48. data/test/dsl_loader_test.rb +23 -0
  49. data/test/job_command_test.rb +29 -0
  50. data/test/systemd_analyze_test.rb +55 -0
  51. data/test/systemd_cron_parser_test.rb +41 -25
  52. data/test/systemd_renderer_errors_test.rb +1 -1
  53. data/test/systemd_renderer_test.rb +6 -0
  54. metadata +25 -2
@@ -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
@@ -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
- unless seconds.is_a?(Integer)
15
- raise ArgumentError, "Duration seconds must be an Integer (got #{seconds.class})"
16
- end
17
-
18
- raise ArgumentError, "Duration seconds must be positive (got #{seconds})" if seconds <= 0
19
-
20
- @seconds = seconds
14
+ @seconds = Validation.positive_integer(seconds, name: "Duration seconds")
21
15
  end
22
16
 
23
17
  def to_i
@@ -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
 
@@ -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.to_s.strip
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
- unless match
27
- raise InvalidIntervalError,
28
- "Invalid interval #{input.inspect}; expected <n>s|m|h|d|w (example: \"5m\")"
29
- end
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
- n = Integer(match[:n], 10)
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 * MULTIPLIERS.fetch(match[:unit])
48
+ n
35
49
  end
50
+ private_class_method :parse_number
36
51
  end
37
52
  end
@@ -4,25 +4,105 @@ module Wheneverd
4
4
  module Job
5
5
  # A oneshot command job rendered as `ExecStart=` in a `systemd` service unit.
6
6
  #
7
- # Note that the command is inserted into `ExecStart=` as-is. If you need shell features like
8
- # pipes, redirects, or environment variable expansion, wrap the command explicitly:
7
+ # This job accepts either:
8
+ #
9
+ # - A String (inserted into `ExecStart=` as-is, after stripping surrounding whitespace), or
10
+ # - An argv Array (formatted/escaped into a systemd-compatible `ExecStart=` string).
11
+ #
12
+ # If you need shell features like pipes, redirects, or environment variable expansion, wrap
13
+ # the command explicitly:
9
14
  #
10
15
  # @example
11
16
  # command "/bin/bash -lc 'echo hello | sed -e s/hello/hi/'"
17
+ #
18
+ # @example argv form (safer argument handling)
19
+ # command ["/bin/bash", "-lc", "echo hello | sed -e s/hello/hi/"]
12
20
  class Command
21
+ SAFE_UNQUOTED = %r{\A[A-Za-z0-9_@%+=:,./-]+\z}.freeze
22
+
23
+ # Rendered `ExecStart=` value.
24
+ #
13
25
  # @return [String]
14
26
  attr_reader :command
15
27
 
16
- # @param command [String] non-empty command to run
28
+ # Original argv form (when constructed with an Array).
29
+ #
30
+ # @return [Array<String>, nil]
31
+ attr_reader :argv
32
+
33
+ # Stable signature used for unit naming.
34
+ #
35
+ # @return [String]
36
+ attr_reader :signature
37
+
38
+ # @param command [String, Array<String>] non-empty command to run
17
39
  def initialize(command:)
18
- unless command.is_a?(String)
19
- raise InvalidCommandError, "Command must be a String (got #{command.class})"
40
+ @argv = nil
41
+ @signature = nil
42
+ @command = nil
43
+
44
+ case command
45
+ when String then init_string(command)
46
+ when Array then init_argv(command)
47
+ else
48
+ raise InvalidCommandError,
49
+ "Command must be a String or an Array (got #{command.class})"
20
50
  end
51
+ end
52
+
53
+ private
54
+
55
+ def init_string(command)
56
+ stripped = command.strip
57
+ raise InvalidCommandError, "Command must not be empty" if stripped.empty?
58
+
59
+ @command = stripped
60
+ @signature = "command:#{@command}"
61
+ end
62
+
63
+ def init_argv(argv)
64
+ normalized = normalize_argv(argv)
65
+ @argv = normalized
66
+ @command = format_execstart(normalized)
67
+ @signature = ["command:argv", normalized.join("\n")].join("\n")
68
+ end
21
69
 
22
- command_stripped = command.strip
23
- raise InvalidCommandError, "Command must not be empty" if command_stripped.empty?
70
+ def normalize_argv(argv)
71
+ raise InvalidCommandError, "Command argv must not be empty" if argv.empty?
72
+
73
+ elements = argv.map { |arg| validate_argv_element(arg) }
74
+ elements[0] = elements[0].strip
75
+ raise InvalidCommandError, "Command argv executable must not be empty" if elements[0].empty?
76
+
77
+ elements
78
+ end
79
+
80
+ def validate_argv_element(arg)
81
+ unless arg.is_a?(String)
82
+ raise InvalidCommandError, "Command argv elements must be Strings (got #{arg.class})"
83
+ end
84
+
85
+ if arg.match?(/[\0\r\n]/)
86
+ raise InvalidCommandError,
87
+ "Command argv elements must not include NUL or newlines"
88
+ end
89
+
90
+ arg
91
+ end
92
+
93
+ def format_execstart(argv)
94
+ argv.map { |arg| format_exec_arg(arg) }.join(" ")
95
+ end
96
+
97
+ def format_exec_arg(arg)
98
+ return "\"\"" if arg.empty?
99
+ return arg if SAFE_UNQUOTED.match?(arg)
100
+
101
+ "\"#{escape_exec_arg(arg)}\""
102
+ end
24
103
 
25
- @command = command_stripped
104
+ def escape_exec_arg(arg)
105
+ arg.gsub(/[\\"]/) { |m| "\\#{m}" }
26
106
  end
27
107
  end
28
108
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Wheneverd
6
+ module Systemd
7
+ # Thin wrapper around `systemd-analyze`.
8
+ class Analyze
9
+ DEFAULT_SYSTEMD_ANALYZE = "systemd-analyze"
10
+
11
+ # Run `systemd-analyze calendar <value>`.
12
+ #
13
+ # @param value [String] an `OnCalendar=` value
14
+ # @param systemd_analyze [String] path to the `systemd-analyze` executable
15
+ # @return [Array(String, String)] stdout and stderr
16
+ # @raise [Wheneverd::Systemd::SystemdAnalyzeError]
17
+ def self.calendar(value, systemd_analyze: DEFAULT_SYSTEMD_ANALYZE)
18
+ run(systemd_analyze, "calendar", value.to_s)
19
+ end
20
+
21
+ # Run `systemd-analyze verify` for unit files.
22
+ #
23
+ # @param paths [Array<String>] unit file paths to verify
24
+ # @param user [Boolean] verify user units with `--user` (default: true)
25
+ # @param systemd_analyze [String] path to the `systemd-analyze` executable
26
+ # @return [Array(String, String)] stdout and stderr
27
+ # @raise [Wheneverd::Systemd::SystemdAnalyzeError]
28
+ def self.verify(paths, user: true, systemd_analyze: DEFAULT_SYSTEMD_ANALYZE)
29
+ run(systemd_analyze, "verify", *Array(paths).map(&:to_s), user: user)
30
+ end
31
+
32
+ def self.run(systemd_analyze, *args, user: false)
33
+ cmd = [systemd_analyze.to_s]
34
+ cmd << "--user" if user
35
+ cmd.concat(args.flatten.map(&:to_s))
36
+
37
+ stdout, stderr, status = Open3.capture3(*cmd)
38
+ raise SystemdAnalyzeError, format_error(cmd, status, stdout, stderr) unless status.success?
39
+
40
+ [stdout, stderr]
41
+ rescue Errno::ENOENT
42
+ raise SystemdAnalyzeError, "systemd-analyze not found (tried: #{systemd_analyze})"
43
+ end
44
+
45
+ def self.format_error(cmd, status, stdout, stderr)
46
+ details = []
47
+ details << "command: #{cmd.join(' ')}"
48
+ details << "status: #{status.exitstatus}"
49
+ details << "stdout: #{stdout.strip}" unless stdout.to_s.strip.empty?
50
+ details << "stderr: #{stderr.strip}" unless stderr.to_s.strip.empty?
51
+ "systemd-analyze failed (#{details.join(', ')})"
52
+ end
53
+ private_class_method :format_error
54
+ end
55
+ end
56
+ end