wheneverd 0.1.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 +7 -0
- data/.gitignore +26 -0
- data/.rubocop.yml +41 -0
- data/.yardopts +8 -0
- data/AGENTS.md +42 -0
- data/CHANGELOG.md +28 -0
- data/FEATURE_SUMMARY.md +38 -0
- data/Gemfile +16 -0
- data/Gemfile.lock +129 -0
- data/LICENSE +21 -0
- data/README.md +204 -0
- data/Rakefile +196 -0
- data/bin/console +8 -0
- data/bin/setup +5 -0
- data/exe/wheneverd +9 -0
- data/lib/wheneverd/cli/activate.rb +19 -0
- data/lib/wheneverd/cli/current.rb +22 -0
- data/lib/wheneverd/cli/deactivate.rb +19 -0
- data/lib/wheneverd/cli/delete.rb +20 -0
- data/lib/wheneverd/cli/help.rb +18 -0
- data/lib/wheneverd/cli/init.rb +78 -0
- data/lib/wheneverd/cli/reload.rb +40 -0
- data/lib/wheneverd/cli/show.rb +23 -0
- data/lib/wheneverd/cli/write.rb +32 -0
- data/lib/wheneverd/cli.rb +87 -0
- data/lib/wheneverd/core_ext/numeric_duration.rb +56 -0
- data/lib/wheneverd/dsl/at_normalizer.rb +48 -0
- data/lib/wheneverd/dsl/calendar_symbol_period_list.rb +42 -0
- data/lib/wheneverd/dsl/context.rb +72 -0
- data/lib/wheneverd/dsl/errors.rb +29 -0
- data/lib/wheneverd/dsl/loader.rb +49 -0
- data/lib/wheneverd/dsl/period_parser.rb +135 -0
- data/lib/wheneverd/duration.rb +27 -0
- data/lib/wheneverd/entry.rb +31 -0
- data/lib/wheneverd/errors.rb +9 -0
- data/lib/wheneverd/interval.rb +37 -0
- data/lib/wheneverd/job/command.rb +29 -0
- data/lib/wheneverd/schedule.rb +25 -0
- data/lib/wheneverd/systemd/calendar_spec.rb +109 -0
- data/lib/wheneverd/systemd/cron_parser.rb +352 -0
- data/lib/wheneverd/systemd/errors.rb +23 -0
- data/lib/wheneverd/systemd/renderer.rb +153 -0
- data/lib/wheneverd/systemd/systemctl.rb +38 -0
- data/lib/wheneverd/systemd/time_parser.rb +75 -0
- data/lib/wheneverd/systemd/unit_deleter.rb +64 -0
- data/lib/wheneverd/systemd/unit_lister.rb +59 -0
- data/lib/wheneverd/systemd/unit_namer.rb +69 -0
- data/lib/wheneverd/systemd/unit_writer.rb +132 -0
- data/lib/wheneverd/trigger/boot.rb +26 -0
- data/lib/wheneverd/trigger/calendar.rb +26 -0
- data/lib/wheneverd/trigger/interval.rb +30 -0
- data/lib/wheneverd/version.rb +6 -0
- data/lib/wheneverd.rb +41 -0
- data/test/cli_activate_test.rb +110 -0
- data/test/cli_current_test.rb +94 -0
- data/test/cli_deactivate_test.rb +111 -0
- data/test/cli_end_to_end_test.rb +98 -0
- data/test/cli_reload_test.rb +132 -0
- data/test/cli_systemctl_integration_test.rb +76 -0
- data/test/cli_systemd_analyze_test.rb +64 -0
- data/test/cli_test.rb +332 -0
- data/test/domain_model_test.rb +108 -0
- data/test/dsl_calendar_symbol_period_list_test.rb +53 -0
- data/test/dsl_loader_test.rb +384 -0
- data/test/support/cli_subprocess_test_helpers.rb +38 -0
- data/test/support/cli_test_helpers.rb +114 -0
- data/test/systemd_calendar_spec_test.rb +45 -0
- data/test/systemd_cron_parser_test.rb +114 -0
- data/test/systemd_renderer_errors_test.rb +85 -0
- data/test/systemd_renderer_test.rb +161 -0
- data/test/systemd_systemctl_test.rb +46 -0
- data/test/systemd_time_parser_test.rb +25 -0
- data/test/systemd_unit_deleter_test.rb +83 -0
- data/test/systemd_unit_writer_prune_test.rb +85 -0
- data/test/systemd_unit_writer_test.rb +71 -0
- data/test/test_helper.rb +34 -0
- data/test/wheneverd_test.rb +9 -0
- data/wheneverd.gemspec +35 -0
- metadata +136 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Wheneverd
|
|
6
|
+
module Systemd
|
|
7
|
+
# Deletes previously generated unit files for a given identifier.
|
|
8
|
+
class UnitDeleter
|
|
9
|
+
DEFAULT_UNIT_DIR = UnitWriter::DEFAULT_UNIT_DIR
|
|
10
|
+
|
|
11
|
+
# @param identifier [String]
|
|
12
|
+
# @param unit_dir [String]
|
|
13
|
+
# @param dry_run [Boolean] return paths without deleting
|
|
14
|
+
# @return [Array<String>] deleted paths
|
|
15
|
+
def self.delete(identifier:, unit_dir: DEFAULT_UNIT_DIR, dry_run: false)
|
|
16
|
+
dest_dir = File.expand_path(unit_dir.to_s)
|
|
17
|
+
return [] unless Dir.exist?(dest_dir)
|
|
18
|
+
|
|
19
|
+
deleted = deletable_paths(dest_dir, basename_pattern(identifier))
|
|
20
|
+
deleted.each { |path| FileUtils.rm_f(path) unless dry_run }
|
|
21
|
+
deleted
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.deletable_paths(dest_dir, pattern)
|
|
25
|
+
Dir.children(dest_dir).sort.filter_map do |basename|
|
|
26
|
+
next unless pattern.match?(basename)
|
|
27
|
+
|
|
28
|
+
path = File.join(dest_dir, basename)
|
|
29
|
+
next unless File.file?(path)
|
|
30
|
+
next unless generated_marker?(path)
|
|
31
|
+
|
|
32
|
+
path
|
|
33
|
+
end
|
|
34
|
+
end
|
|
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
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
module Systemd
|
|
5
|
+
# Lists previously generated unit file paths for a given identifier.
|
|
6
|
+
class UnitLister
|
|
7
|
+
DEFAULT_UNIT_DIR = UnitWriter::DEFAULT_UNIT_DIR
|
|
8
|
+
|
|
9
|
+
# @param identifier [String]
|
|
10
|
+
# @param unit_dir [String]
|
|
11
|
+
# @return [Array<String>] unit file paths
|
|
12
|
+
def self.list(identifier:, unit_dir: DEFAULT_UNIT_DIR)
|
|
13
|
+
dest_dir = File.expand_path(unit_dir.to_s)
|
|
14
|
+
return [] unless Dir.exist?(dest_dir)
|
|
15
|
+
|
|
16
|
+
unit_paths(dest_dir, basename_pattern(identifier))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.unit_paths(dest_dir, pattern)
|
|
20
|
+
Dir.children(dest_dir).sort.filter_map do |basename|
|
|
21
|
+
next unless pattern.match?(basename)
|
|
22
|
+
|
|
23
|
+
path = File.join(dest_dir, basename)
|
|
24
|
+
next unless File.file?(path)
|
|
25
|
+
next unless generated_marker?(path)
|
|
26
|
+
|
|
27
|
+
path
|
|
28
|
+
end
|
|
29
|
+
end
|
|
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
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Wheneverd
|
|
6
|
+
module Systemd
|
|
7
|
+
# Computes stable unit IDs for jobs so units keep names across schedule reordering.
|
|
8
|
+
class UnitNamer
|
|
9
|
+
def self.stable_ids_for(schedule)
|
|
10
|
+
signatures = signatures_for(schedule)
|
|
11
|
+
counts_by_signature = signatures.tally
|
|
12
|
+
occurrences_by_signature = Hash.new(0)
|
|
13
|
+
|
|
14
|
+
signatures.map do |sig|
|
|
15
|
+
disambiguate(stable_id_for(sig), sig, counts_by_signature, occurrences_by_signature)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.signatures_for(schedule)
|
|
20
|
+
schedule.entries.flat_map do |entry|
|
|
21
|
+
entry.jobs.map { |job| signature(entry.trigger, job) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
private_class_method :signatures_for
|
|
25
|
+
|
|
26
|
+
def self.disambiguate(stable, signature, counts_by_signature, occurrences_by_signature)
|
|
27
|
+
return stable if counts_by_signature.fetch(signature) == 1
|
|
28
|
+
|
|
29
|
+
occurrences_by_signature[signature] += 1
|
|
30
|
+
"#{stable}-#{occurrences_by_signature.fetch(signature)}"
|
|
31
|
+
end
|
|
32
|
+
private_class_method :disambiguate
|
|
33
|
+
|
|
34
|
+
def self.signature(trigger, job)
|
|
35
|
+
[trigger_signature(trigger), job_signature(job)].join("\n")
|
|
36
|
+
end
|
|
37
|
+
private_class_method :signature
|
|
38
|
+
|
|
39
|
+
def self.trigger_signature(trigger)
|
|
40
|
+
case trigger
|
|
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
|
|
48
|
+
raise ArgumentError, "Unsupported trigger type: #{trigger.class}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
private_class_method :trigger_signature
|
|
52
|
+
|
|
53
|
+
def self.job_signature(job)
|
|
54
|
+
case job
|
|
55
|
+
when Wheneverd::Job::Command
|
|
56
|
+
"command:#{job.command}"
|
|
57
|
+
else
|
|
58
|
+
raise ArgumentError, "Unsupported job type: #{job.class}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
private_class_method :job_signature
|
|
62
|
+
|
|
63
|
+
def self.stable_id_for(signature)
|
|
64
|
+
Digest::SHA256.hexdigest(signature).slice(0, 12)
|
|
65
|
+
end
|
|
66
|
+
private_class_method :stable_id_for
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module Wheneverd
|
|
7
|
+
module Systemd
|
|
8
|
+
# Writes rendered systemd units to a target directory (defaulting to the user unit dir).
|
|
9
|
+
class UnitWriter
|
|
10
|
+
DEFAULT_UNIT_DIR = File.join(Dir.home, ".config", "systemd", "user").freeze
|
|
11
|
+
|
|
12
|
+
# @param units [Array<Wheneverd::Systemd::Unit>]
|
|
13
|
+
# @param identifier [String, nil] required when pruning
|
|
14
|
+
# @param unit_dir [String]
|
|
15
|
+
# @param dry_run [Boolean] return paths without writing
|
|
16
|
+
# @param prune [Boolean] delete previously generated units for `identifier` not in `units`
|
|
17
|
+
# @return [Array<String>] destination paths
|
|
18
|
+
def self.write(units, unit_dir: DEFAULT_UNIT_DIR, dry_run: false, prune: false,
|
|
19
|
+
identifier: nil)
|
|
20
|
+
validate_units!(units)
|
|
21
|
+
dest_dir = File.expand_path(unit_dir.to_s)
|
|
22
|
+
paths = destination_paths(units, dest_dir)
|
|
23
|
+
return paths if dry_run
|
|
24
|
+
|
|
25
|
+
write_units(units, paths, dest_dir, prune: prune, identifier: identifier)
|
|
26
|
+
paths
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.validate_units!(units)
|
|
30
|
+
raise ArgumentError, "units must be an Array" unless units.is_a?(Array)
|
|
31
|
+
end
|
|
32
|
+
private_class_method :validate_units!
|
|
33
|
+
|
|
34
|
+
def self.destination_paths(units, dest_dir)
|
|
35
|
+
units.map { |unit| File.join(dest_dir, unit.path_basename) }
|
|
36
|
+
end
|
|
37
|
+
private_class_method :destination_paths
|
|
38
|
+
|
|
39
|
+
def self.write_units(units, paths, dest_dir, prune:, identifier:)
|
|
40
|
+
FileUtils.mkdir_p(dest_dir)
|
|
41
|
+
if prune
|
|
42
|
+
prune_stale_units(
|
|
43
|
+
dest_dir,
|
|
44
|
+
identifier: identifier,
|
|
45
|
+
keep_basenames: units.map(&:path_basename)
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
write_unit_files(units, paths, dest_dir)
|
|
49
|
+
end
|
|
50
|
+
private_class_method :write_units
|
|
51
|
+
|
|
52
|
+
def self.write_unit_files(units, paths, dest_dir)
|
|
53
|
+
units.each_with_index do |unit, idx|
|
|
54
|
+
atomic_write(paths.fetch(idx), unit.contents.to_s, dir: dest_dir)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
private_class_method :write_unit_files
|
|
58
|
+
|
|
59
|
+
def self.prune_stale_units(dest_dir, identifier:, keep_basenames:)
|
|
60
|
+
raise ArgumentError, "identifier is required when prune is true" if identifier.nil?
|
|
61
|
+
|
|
62
|
+
keep = keep_basenames.to_h { |basename| [basename, true] }
|
|
63
|
+
stale_unit_paths(dest_dir, identifier: identifier, keep: keep).each do |path|
|
|
64
|
+
FileUtils.rm_f(path)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
private_class_method :prune_stale_units
|
|
68
|
+
|
|
69
|
+
def self.stale_unit_paths(dest_dir, identifier:, keep:)
|
|
70
|
+
pattern = basename_pattern(identifier)
|
|
71
|
+
Dir.children(dest_dir).filter_map do |basename|
|
|
72
|
+
next if keep.key?(basename)
|
|
73
|
+
|
|
74
|
+
path = File.join(dest_dir, basename)
|
|
75
|
+
next unless stale_unit_path?(basename, path, pattern)
|
|
76
|
+
|
|
77
|
+
path
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
private_class_method :stale_unit_paths
|
|
81
|
+
|
|
82
|
+
def self.stale_unit_path?(basename, path, pattern)
|
|
83
|
+
return false unless pattern.match?(basename)
|
|
84
|
+
return false unless File.file?(path)
|
|
85
|
+
|
|
86
|
+
generated_marker?(path)
|
|
87
|
+
end
|
|
88
|
+
private_class_method :stale_unit_path?
|
|
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
|
+
def self.atomic_write(dest_path, contents, dir:)
|
|
117
|
+
basename = File.basename(dest_path)
|
|
118
|
+
tmp = Tempfile.new([".#{basename}.", ".tmp"], dir)
|
|
119
|
+
tmp.write(contents)
|
|
120
|
+
tmp.flush
|
|
121
|
+
tmp.fsync
|
|
122
|
+
tmp.close
|
|
123
|
+
|
|
124
|
+
File.rename(tmp.path, dest_path)
|
|
125
|
+
ensure
|
|
126
|
+
tmp&.close
|
|
127
|
+
tmp&.unlink
|
|
128
|
+
end
|
|
129
|
+
private_class_method :atomic_write
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
module Trigger
|
|
5
|
+
# A boot trigger, rendered as `OnBootSec=`.
|
|
6
|
+
class Boot
|
|
7
|
+
# @return [Integer]
|
|
8
|
+
attr_reader :seconds
|
|
9
|
+
|
|
10
|
+
# @param seconds [Integer] seconds after boot (must be positive)
|
|
11
|
+
def initialize(seconds:)
|
|
12
|
+
unless seconds.is_a?(Integer)
|
|
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
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Array<String>] systemd `[Timer]` lines for this trigger
|
|
21
|
+
def systemd_timer_lines
|
|
22
|
+
["OnBootSec=#{seconds}"]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
module Trigger
|
|
5
|
+
# A calendar trigger, rendered as one or more `OnCalendar=` lines.
|
|
6
|
+
class Calendar
|
|
7
|
+
# @return [Array<String>] calendar specs (already in `systemd` OnCalendar format)
|
|
8
|
+
attr_reader :on_calendar
|
|
9
|
+
|
|
10
|
+
# @param on_calendar [Array<String>] non-empty calendar specs
|
|
11
|
+
def initialize(on_calendar:)
|
|
12
|
+
unless on_calendar.is_a?(Array) && !on_calendar.empty? &&
|
|
13
|
+
on_calendar.all? { |v| v.is_a?(String) && !v.strip.empty? }
|
|
14
|
+
raise ArgumentError, "on_calendar must be a non-empty Array of non-empty Strings"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
@on_calendar = on_calendar.map(&:strip)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Array<String>] systemd `[Timer]` lines for this trigger
|
|
21
|
+
def systemd_timer_lines
|
|
22
|
+
on_calendar.map { |spec| "OnCalendar=#{spec}" }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
module Trigger
|
|
5
|
+
# A monotonic interval trigger for `systemd` timers.
|
|
6
|
+
#
|
|
7
|
+
# We emit both:
|
|
8
|
+
# - `OnActiveSec=` to schedule the first run relative to timer activation.
|
|
9
|
+
# - `OnUnitActiveSec=` to schedule subsequent runs relative to the last run.
|
|
10
|
+
class Interval
|
|
11
|
+
# @return [Integer]
|
|
12
|
+
attr_reader :seconds
|
|
13
|
+
|
|
14
|
+
# @param seconds [Integer] seconds between runs (must be positive)
|
|
15
|
+
def initialize(seconds:)
|
|
16
|
+
unless seconds.is_a?(Integer)
|
|
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
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Array<String>] systemd `[Timer]` lines for this trigger
|
|
25
|
+
def systemd_timer_lines
|
|
26
|
+
["OnActiveSec=#{seconds}", "OnUnitActiveSec=#{seconds}"]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/wheneverd.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "wheneverd/version"
|
|
4
|
+
|
|
5
|
+
module Wheneverd
|
|
6
|
+
# Top-level namespace for `wheneverd`.
|
|
7
|
+
#
|
|
8
|
+
# This gem loads a Ruby schedule DSL (similar to the `whenever` gem), then renders the result into
|
|
9
|
+
# `systemd` timer + service units. The main entrypoints are:
|
|
10
|
+
#
|
|
11
|
+
# - {Wheneverd::DSL::Loader} for evaluating `config/schedule.rb`
|
|
12
|
+
# - {Wheneverd::Systemd::Renderer} for generating unit contents
|
|
13
|
+
# - {Wheneverd::CLI} for the command-line interface
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
require_relative "wheneverd/errors"
|
|
18
|
+
require_relative "wheneverd/duration"
|
|
19
|
+
require_relative "wheneverd/interval"
|
|
20
|
+
require_relative "wheneverd/core_ext/numeric_duration"
|
|
21
|
+
require_relative "wheneverd/job/command"
|
|
22
|
+
require_relative "wheneverd/trigger/interval"
|
|
23
|
+
require_relative "wheneverd/trigger/calendar"
|
|
24
|
+
require_relative "wheneverd/trigger/boot"
|
|
25
|
+
require_relative "wheneverd/entry"
|
|
26
|
+
require_relative "wheneverd/schedule"
|
|
27
|
+
require_relative "wheneverd/dsl/errors"
|
|
28
|
+
require_relative "wheneverd/dsl/at_normalizer"
|
|
29
|
+
require_relative "wheneverd/dsl/period_parser"
|
|
30
|
+
require_relative "wheneverd/dsl/context"
|
|
31
|
+
require_relative "wheneverd/dsl/loader"
|
|
32
|
+
require_relative "wheneverd/systemd/errors"
|
|
33
|
+
require_relative "wheneverd/systemd/time_parser"
|
|
34
|
+
require_relative "wheneverd/systemd/cron_parser"
|
|
35
|
+
require_relative "wheneverd/systemd/calendar_spec"
|
|
36
|
+
require_relative "wheneverd/systemd/unit_namer"
|
|
37
|
+
require_relative "wheneverd/systemd/renderer"
|
|
38
|
+
require_relative "wheneverd/systemd/systemctl"
|
|
39
|
+
require_relative "wheneverd/systemd/unit_writer"
|
|
40
|
+
require_relative "wheneverd/systemd/unit_deleter"
|
|
41
|
+
require_relative "wheneverd/systemd/unit_lister"
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
require_relative "support/cli_test_helpers"
|
|
5
|
+
|
|
6
|
+
class CLIActivateSuccessTest < Minitest::Test
|
|
7
|
+
include CLITestHelpers
|
|
8
|
+
|
|
9
|
+
def test_exits_zero
|
|
10
|
+
with_inited_project_dir do
|
|
11
|
+
status, _out, _err, _calls = run_activate_with_capture3_stub
|
|
12
|
+
assert_equal 0, status
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def test_prints_timer_units
|
|
17
|
+
with_inited_project_dir do
|
|
18
|
+
status, out, err, _calls = run_activate_with_capture3_stub
|
|
19
|
+
assert_cli_success(status, err)
|
|
20
|
+
assert_includes out, expected_timer_basenames.fetch(0)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def test_runs_daemon_reload
|
|
25
|
+
with_inited_project_dir do
|
|
26
|
+
status, _out, err, calls = run_activate_with_capture3_stub
|
|
27
|
+
assert_cli_success(status, err)
|
|
28
|
+
assert_systemctl_call(calls, 0, SYSTEMCTL_USER_PREFIX + ["daemon-reload"])
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def test_runs_enable_now
|
|
33
|
+
with_inited_project_dir do
|
|
34
|
+
status, _out, err, calls = run_activate_with_capture3_stub
|
|
35
|
+
assert_cli_success(status, err)
|
|
36
|
+
assert_systemctl_call_starts_with(calls, 1, SYSTEMCTL_USER_PREFIX + ["enable", "--now"],
|
|
37
|
+
includes: expected_timer_basenames)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class CLIActivateScheduleMissingTest < Minitest::Test
|
|
43
|
+
include CLITestHelpers
|
|
44
|
+
|
|
45
|
+
def test_exits_one
|
|
46
|
+
with_project_dir do
|
|
47
|
+
status, _out, _err = run_cli(["activate"])
|
|
48
|
+
assert_equal 1, status
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def test_prints_error_message
|
|
53
|
+
with_project_dir do
|
|
54
|
+
_status, out, err = run_cli(["activate"])
|
|
55
|
+
assert_equal "", out
|
|
56
|
+
assert_includes err, "Schedule file not found"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class CLIActivateEmptyScheduleTest < Minitest::Test
|
|
62
|
+
include CLITestHelpers
|
|
63
|
+
|
|
64
|
+
def test_makes_no_systemctl_calls
|
|
65
|
+
with_project_dir do
|
|
66
|
+
write_empty_schedule
|
|
67
|
+
status, _out, err, calls = run_activate_with_capture3_stub
|
|
68
|
+
assert_cli_success(status, err)
|
|
69
|
+
assert_equal [], calls
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def test_prints_nothing
|
|
74
|
+
with_project_dir do
|
|
75
|
+
write_empty_schedule
|
|
76
|
+
status, out, err, _calls = run_activate_with_capture3_stub
|
|
77
|
+
assert_cli_success(status, err)
|
|
78
|
+
assert_equal "", out
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class CLIActivateSystemctlFailureTest < Minitest::Test
|
|
84
|
+
include CLITestHelpers
|
|
85
|
+
|
|
86
|
+
def test_exits_one
|
|
87
|
+
with_inited_project_dir do
|
|
88
|
+
status, _out, _err, _calls = run_activate_with_capture3_stub(exitstatus: 1,
|
|
89
|
+
stderr: "no bus\n")
|
|
90
|
+
assert_equal 1, status
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def test_prints_systemctl_failed
|
|
95
|
+
with_inited_project_dir do
|
|
96
|
+
_status, _out, err, _calls = run_activate_with_capture3_stub(exitstatus: 1,
|
|
97
|
+
stderr: "no bus\n")
|
|
98
|
+
assert_includes err, "systemctl failed"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def test_only_calls_daemon_reload
|
|
103
|
+
with_inited_project_dir do
|
|
104
|
+
_status, _out, _err, calls = run_activate_with_capture3_stub(exitstatus: 1,
|
|
105
|
+
stderr: "no bus\n")
|
|
106
|
+
assert_equal 1, calls.length
|
|
107
|
+
assert_systemctl_call(calls, 0, SYSTEMCTL_USER_PREFIX + ["daemon-reload"])
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
require_relative "support/cli_test_helpers"
|
|
5
|
+
|
|
6
|
+
class CLICurrentInstalledUnitsTest < Minitest::Test
|
|
7
|
+
include CLITestHelpers
|
|
8
|
+
|
|
9
|
+
def test_exits_zero
|
|
10
|
+
with_inited_project_dir do |project_dir|
|
|
11
|
+
unit_dir = File.join(project_dir, "tmp_units")
|
|
12
|
+
assert_equal 0, run_cli(["write", "--identifier", "demo", "--unit-dir", unit_dir]).first
|
|
13
|
+
status, _out, err = run_cli(["current", "--identifier", "demo", "--unit-dir", unit_dir])
|
|
14
|
+
assert_cli_success(status, err)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_includes_header_for_generated_units
|
|
19
|
+
with_inited_project_dir do |project_dir|
|
|
20
|
+
unit_dir = File.join(project_dir, "tmp_units")
|
|
21
|
+
assert_equal 0, run_cli(["write", "--identifier", "demo", "--unit-dir", unit_dir]).first
|
|
22
|
+
status, out, err = run_cli(["current", "--identifier", "demo", "--unit-dir", unit_dir])
|
|
23
|
+
assert_cli_success(status, err)
|
|
24
|
+
assert_includes out, "# #{File.join(unit_dir, expected_timer_basenames.fetch(0))}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def test_includes_generated_marker
|
|
29
|
+
with_inited_project_dir do |project_dir|
|
|
30
|
+
unit_dir = File.join(project_dir, "tmp_units")
|
|
31
|
+
assert_equal 0, run_cli(["write", "--identifier", "demo", "--unit-dir", unit_dir]).first
|
|
32
|
+
status, out, err = run_cli(["current", "--identifier", "demo", "--unit-dir", unit_dir])
|
|
33
|
+
assert_cli_success(status, err)
|
|
34
|
+
assert_includes out, Wheneverd::Systemd::Renderer::MARKER_PREFIX
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def test_excludes_units_without_generated_marker
|
|
39
|
+
with_inited_project_dir do |project_dir|
|
|
40
|
+
unit_dir = File.join(project_dir, "tmp_units")
|
|
41
|
+
assert_equal 0, run_cli(["write", "--identifier", "demo", "--unit-dir", unit_dir]).first
|
|
42
|
+
File.write(File.join(unit_dir, "wheneverd-demo-000000000000.timer"), "# not generated\n")
|
|
43
|
+
status, out, err = run_cli(["current", "--identifier", "demo", "--unit-dir", unit_dir])
|
|
44
|
+
assert_cli_success(status, err)
|
|
45
|
+
refute_includes out, "wheneverd-demo-000000000000.timer"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class CLICurrentInvalidIdentifierTest < Minitest::Test
|
|
51
|
+
include CLITestHelpers
|
|
52
|
+
|
|
53
|
+
def test_exits_one
|
|
54
|
+
with_project_dir do |project_dir|
|
|
55
|
+
unit_dir = File.join(project_dir, "tmp_units")
|
|
56
|
+
FileUtils.mkdir_p(unit_dir)
|
|
57
|
+
status, _out, _err = run_cli(["current", "--identifier", "!!!", "--unit-dir", unit_dir])
|
|
58
|
+
assert_equal 1, status
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def test_prints_error_message
|
|
63
|
+
with_project_dir do |project_dir|
|
|
64
|
+
unit_dir = File.join(project_dir, "tmp_units")
|
|
65
|
+
FileUtils.mkdir_p(unit_dir)
|
|
66
|
+
_status, out, err = run_cli(["current", "--identifier", "!!!", "--unit-dir", unit_dir])
|
|
67
|
+
assert_equal "", out
|
|
68
|
+
assert_includes err, "identifier must include at least one alphanumeric character"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class CLICurrentEmptyUnitDirTest < Minitest::Test
|
|
74
|
+
include CLITestHelpers
|
|
75
|
+
|
|
76
|
+
def test_prints_nothing_when_unit_dir_missing
|
|
77
|
+
with_project_dir do |project_dir|
|
|
78
|
+
unit_dir = File.join(project_dir, "missing_units")
|
|
79
|
+
status, out, err = run_cli(["current", "--identifier", "demo", "--unit-dir", unit_dir])
|
|
80
|
+
assert_cli_success(status, err)
|
|
81
|
+
assert_equal "", out
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def test_prints_nothing_when_no_units_installed
|
|
86
|
+
with_project_dir do |project_dir|
|
|
87
|
+
unit_dir = File.join(project_dir, "empty_units")
|
|
88
|
+
FileUtils.mkdir_p(unit_dir)
|
|
89
|
+
status, out, err = run_cli(["current", "--identifier", "demo", "--unit-dir", unit_dir])
|
|
90
|
+
assert_cli_success(status, err)
|
|
91
|
+
assert_equal "", out
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|