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
|
@@ -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
|
|
@@ -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
|
-
#
|
|
8
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
raise InvalidCommandError, "Command must not be empty" if
|
|
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
|
-
|
|
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
|