wheneverd 0.2.1 → 0.3.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 +6 -0
- data/FEATURE_SUMMARY.md +10 -1
- data/Gemfile.lock +2 -2
- data/README.md +25 -3
- 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/job/command.rb +88 -8
- data/lib/wheneverd/systemd/analyze.rb +56 -0
- data/lib/wheneverd/systemd/errors.rb +3 -0
- data/lib/wheneverd/systemd/unit_namer.rb +1 -1
- data/lib/wheneverd/version.rb +1 -1
- data/lib/wheneverd.rb +1 -0
- 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 +1 -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_renderer_test.rb +6 -0
- metadata +13 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c4d813e1e0ad0b44e738d4ba42767b5fb490dcaf5a7883328c91faa2ab726305
|
|
4
|
+
data.tar.gz: 1185929834d9f12adb4afd8a1bc2d1a4a366d99b260afe1d075a1ca58dc6dc26
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ed43947461d6e8dadf5825c8075e08816535c42202a9764c7b57d1fb4a7948a0dbfe1ff57e13cc710a853efa6910f802b93774b239736ed30bcec145812c08fc
|
|
7
|
+
data.tar.gz: 4406adc4f70afde1953cd8ea82979d2c37bf6b096232930724f2537b04d5ae187437fafa29215163ebd985eb132b89c7ee990497ba2cdb70617166b5d88114ee
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,12 @@ On release, entries are moved into `## x.y.z` sections that match the gem versio
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
+
## 0.3.0
|
|
9
|
+
|
|
10
|
+
- Schedule DSL: `command` accepts argv arrays, adds a `shell` helper for `/bin/bash -lc`, and `wheneverd init` includes examples.
|
|
11
|
+
- Adds `wheneverd status` (show `systemctl --user list-timers` + `systemctl --user status` for installed timers) and `wheneverd diff` (diff rendered units vs files on disk).
|
|
12
|
+
- Adds `wheneverd validate` to validate rendered `OnCalendar=` values via `systemd-analyze calendar` (and with `--verify`, runs `systemd-analyze --user verify` on temporary unit files).
|
|
13
|
+
|
|
8
14
|
## 0.2.1
|
|
9
15
|
|
|
10
16
|
- Removes an unused filtering metadata keyword argument from the schedule DSL.
|
data/FEATURE_SUMMARY.md
CHANGED
|
@@ -12,13 +12,22 @@ It complements [`CHANGELOG.md`](CHANGELOG.md) by staying high-level and focusing
|
|
|
12
12
|
|
|
13
13
|
## Unreleased
|
|
14
14
|
|
|
15
|
+
## 0.3.0
|
|
16
|
+
|
|
17
|
+
- Schedule DSL: `command` accepts argv arrays, adds a `shell` helper for `/bin/bash -lc`, and `wheneverd init` includes examples.
|
|
18
|
+
- Adds `wheneverd status` (show `systemctl --user list-timers` + `systemctl --user status` for installed timers) and `wheneverd diff` (diff rendered units vs files on disk).
|
|
19
|
+
- Adds `wheneverd validate` to validate rendered `OnCalendar=` values via `systemd-analyze calendar` (and with `--verify`, runs `systemd-analyze --user verify` on temporary unit files).
|
|
20
|
+
|
|
15
21
|
## 0.2.1
|
|
16
22
|
|
|
17
|
-
- Adds `wheneverd linger enable|disable|status` for managing systemd user lingering via `loginctl`.
|
|
18
23
|
- Removes an unused filtering metadata keyword argument from the schedule DSL.
|
|
19
24
|
|
|
20
25
|
## 0.2.0
|
|
21
26
|
|
|
27
|
+
- Adds `wheneverd linger enable|disable|status` for managing systemd user lingering via `loginctl`.
|
|
28
|
+
|
|
29
|
+
## 0.1.0
|
|
30
|
+
|
|
22
31
|
- The `wheneverd` CLI is implemented using Clamp (`--help`, usage errors in `ERROR: ...` format, `--verbose` for details).
|
|
23
32
|
- The gem includes a small “whenever-like” domain model (interval parsing, durations, triggers, schedules).
|
|
24
33
|
- The gem can load a Ruby schedule DSL file via `Wheneverd::DSL::Loader.load_file`.
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
wheneverd (0.
|
|
4
|
+
wheneverd (0.3.0)
|
|
5
5
|
clamp (~> 1.3)
|
|
6
6
|
|
|
7
7
|
GEM
|
|
@@ -122,7 +122,7 @@ CHECKSUMS
|
|
|
122
122
|
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
|
|
123
123
|
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
|
|
124
124
|
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
|
|
125
|
-
wheneverd (0.
|
|
125
|
+
wheneverd (0.3.0)
|
|
126
126
|
yard (0.9.38) sha256=721fb82afb10532aa49860655f6cc2eaa7130889df291b052e1e6b268283010f
|
|
127
127
|
|
|
128
128
|
BUNDLED WITH
|
data/README.md
CHANGED
|
@@ -34,6 +34,9 @@ bundle install
|
|
|
34
34
|
wheneverd --help
|
|
35
35
|
wheneverd init
|
|
36
36
|
wheneverd show
|
|
37
|
+
wheneverd status
|
|
38
|
+
wheneverd diff
|
|
39
|
+
wheneverd validate
|
|
37
40
|
wheneverd write
|
|
38
41
|
wheneverd delete
|
|
39
42
|
wheneverd activate
|
|
@@ -43,6 +46,8 @@ wheneverd current
|
|
|
43
46
|
wheneverd linger
|
|
44
47
|
```
|
|
45
48
|
|
|
49
|
+
Use `wheneverd init` to create a starter `config/schedule.rb` template (including examples for `command` and `shell`).
|
|
50
|
+
|
|
46
51
|
### Minimal `config/schedule.rb` example
|
|
47
52
|
|
|
48
53
|
```ruby
|
|
@@ -137,13 +142,26 @@ end
|
|
|
137
142
|
|
|
138
143
|
### `command`
|
|
139
144
|
|
|
140
|
-
`command(
|
|
145
|
+
`command(...)` appends a oneshot `ExecStart=` job.
|
|
146
|
+
|
|
147
|
+
Accepted forms:
|
|
141
148
|
|
|
142
|
-
|
|
143
|
-
(
|
|
149
|
+
- `command("...")` (String): inserted into `ExecStart=` as-is (after stripping surrounding whitespace).
|
|
150
|
+
- `command(["bin", "arg1", "arg2"])` (argv Array): formatted/escaped into a systemd-compatible `ExecStart=` string.
|
|
151
|
+
|
|
152
|
+
If you need shell features (pipes, redirects, globbing, env var expansion), either wrap it yourself, or use `shell`:
|
|
144
153
|
|
|
145
154
|
```ruby
|
|
146
155
|
command "/bin/bash -lc 'echo hello | sed -e s/hello/hi/'"
|
|
156
|
+
command ["/bin/bash", "-lc", "echo hello | sed -e s/hello/hi/"]
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### `shell`
|
|
160
|
+
|
|
161
|
+
`shell("...")` is a convenience helper for the common `/bin/bash -lc` pattern:
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
shell "echo hello | sed -e s/hello/hi/"
|
|
147
165
|
```
|
|
148
166
|
|
|
149
167
|
### `every` periods
|
|
@@ -203,11 +221,15 @@ Notes:
|
|
|
203
221
|
- Unit basenames include a stable ID derived from the job’s trigger + command (reordering schedule blocks won’t rename units).
|
|
204
222
|
- `wheneverd write` / `wheneverd reload` prune previously generated units for the identifier by default (use `--no-prune` to keep old units around).
|
|
205
223
|
- `--unit-dir` controls where unit files are written/read/deleted; `activate`/`deactivate` use systemd’s unit search path.
|
|
224
|
+
- `wheneverd diff` returns exit status `0` when no differences are found, and `1` when differences are found.
|
|
206
225
|
|
|
207
226
|
Commands:
|
|
208
227
|
|
|
209
228
|
- `wheneverd init [--schedule PATH] [--force]` writes a template schedule file.
|
|
210
229
|
- `wheneverd show [--schedule PATH] [--identifier NAME]` prints rendered units to stdout.
|
|
230
|
+
- `wheneverd status [--identifier NAME] [--unit-dir PATH]` prints `systemctl --user list-timers` and `systemctl --user status` for installed timers.
|
|
231
|
+
- `wheneverd diff [--schedule PATH] [--identifier NAME] [--unit-dir PATH]` diffs rendered units vs unit files on disk.
|
|
232
|
+
- `wheneverd validate [--schedule PATH] [--identifier NAME] [--verify]` validates rendered `OnCalendar=` values via `systemd-analyze calendar` (and with `--verify`, runs `systemd-analyze --user verify` on temporary unit files).
|
|
211
233
|
- `wheneverd write [--schedule PATH] [--identifier NAME] [--unit-dir PATH] [--dry-run] [--[no-]prune]` writes units to disk (or prints paths in `--dry-run` mode).
|
|
212
234
|
- `wheneverd delete [--identifier NAME] [--unit-dir PATH] [--dry-run]` deletes previously generated units for the identifier.
|
|
213
235
|
- `wheneverd activate [--schedule PATH] [--identifier NAME]` runs `systemctl --user daemon-reload` and enables/starts the timers.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
# Implements `wheneverd diff` (rendered output vs files on disk).
|
|
5
|
+
#
|
|
6
|
+
# Exit statuses follow `diff` semantics:
|
|
7
|
+
# - 0: no differences
|
|
8
|
+
# - 1: differences found
|
|
9
|
+
# - 2: error
|
|
10
|
+
class CLI::Diff < CLI
|
|
11
|
+
def execute
|
|
12
|
+
diffs = unit_diffs
|
|
13
|
+
return 0 if diffs.empty?
|
|
14
|
+
|
|
15
|
+
diffs.each_with_index do |diff, idx|
|
|
16
|
+
puts "" if idx.positive?
|
|
17
|
+
puts diff
|
|
18
|
+
end
|
|
19
|
+
1
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
handle_error(e)
|
|
22
|
+
2
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def unit_diffs
|
|
28
|
+
rendered = rendered_units_by_basename
|
|
29
|
+
installed = installed_units_by_basename
|
|
30
|
+
|
|
31
|
+
diffs = rendered.map do |basename, contents|
|
|
32
|
+
diff_for_rendered_unit(basename, contents, installed[basename])
|
|
33
|
+
end
|
|
34
|
+
diffs.concat(stale_unit_diffs(rendered: rendered, installed: installed))
|
|
35
|
+
diffs.compact
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def stale_unit_diffs(rendered:, installed:)
|
|
39
|
+
(installed.keys - rendered.keys).sort.map do |basename|
|
|
40
|
+
diff_for_removed_unit(basename, installed.fetch(basename))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def rendered_units_by_basename
|
|
45
|
+
render_units.to_h { |unit| [unit.path_basename, unit.contents.to_s] }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def installed_units_by_basename
|
|
49
|
+
paths = Wheneverd::Systemd::UnitLister.list(identifier: identifier_value, unit_dir: unit_dir)
|
|
50
|
+
paths.to_h { |path| [File.basename(path), path] }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def diff_for_rendered_unit(basename, rendered_contents, installed_path)
|
|
54
|
+
return diff_for_added_unit(basename, rendered_contents) if installed_path.nil?
|
|
55
|
+
|
|
56
|
+
installed_contents = File.read(installed_path)
|
|
57
|
+
return nil if installed_contents == rendered_contents
|
|
58
|
+
|
|
59
|
+
diff_for_changed_unit(basename, installed_path, installed_contents, rendered_contents)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def diff_for_added_unit(basename, rendered_contents)
|
|
63
|
+
dest_path = File.join(File.expand_path(unit_dir.to_s), basename)
|
|
64
|
+
header = ["diff --wheneverd #{basename}", "--- /dev/null", "+++ #{dest_path}"]
|
|
65
|
+
(header + line_diff("", rendered_contents)).join("\n")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def diff_for_removed_unit(basename, installed_path)
|
|
69
|
+
header = ["diff --wheneverd #{basename}", "--- #{installed_path}", "+++ /dev/null"]
|
|
70
|
+
(header + line_diff(File.read(installed_path), "")).join("\n")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def diff_for_changed_unit(basename, installed_path, installed_contents, rendered_contents)
|
|
74
|
+
header = ["diff --wheneverd #{basename}", "--- #{installed_path}",
|
|
75
|
+
"+++ #{basename} (rendered)"]
|
|
76
|
+
(header + line_diff(installed_contents, rendered_contents)).join("\n")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def line_diff(old_contents, new_contents)
|
|
80
|
+
old_lines = old_contents.to_s.lines.map(&:chomp)
|
|
81
|
+
new_lines = new_contents.to_s.lines.map(&:chomp)
|
|
82
|
+
max = [old_lines.length, new_lines.length].max
|
|
83
|
+
|
|
84
|
+
(0...max).flat_map do |idx|
|
|
85
|
+
diff_lines_for(old_lines[idx], new_lines[idx])
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def diff_lines_for(old_line, new_line)
|
|
90
|
+
return [] if old_line.nil? && new_line.nil?
|
|
91
|
+
return [" #{old_line}"] if old_line == new_line
|
|
92
|
+
return ["-#{old_line}", "+#{new_line}"] if old_line && new_line
|
|
93
|
+
return ["-#{old_line}"] if old_line
|
|
94
|
+
|
|
95
|
+
["+#{new_line}"]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/wheneverd/cli/init.rb
CHANGED
|
@@ -32,7 +32,7 @@ module Wheneverd
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
every :hour do
|
|
35
|
-
command "echo
|
|
35
|
+
command ["echo", "hello world"]
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
every :sunday, at: "12pm" do
|
|
@@ -43,6 +43,10 @@ module Wheneverd
|
|
|
43
43
|
command "echo midweek"
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
every :day, at: "12:15" do
|
|
47
|
+
shell "echo hello | sed -e s/hello/hi/"
|
|
48
|
+
end
|
|
49
|
+
|
|
46
50
|
every "0 0 27-31 * *" do
|
|
47
51
|
command "echo raw_cron"
|
|
48
52
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wheneverd
|
|
4
|
+
# Implements `wheneverd status` (show installed timer status via `systemctl --user`).
|
|
5
|
+
class CLI::Status < CLI
|
|
6
|
+
def execute
|
|
7
|
+
timer_units = installed_timer_unit_basenames
|
|
8
|
+
return 0 if timer_units.empty?
|
|
9
|
+
|
|
10
|
+
print_list_timers(timer_units)
|
|
11
|
+
print_status(timer_units)
|
|
12
|
+
0
|
|
13
|
+
rescue StandardError => e
|
|
14
|
+
handle_error(e)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# @return [Array<String>]
|
|
20
|
+
def installed_timer_unit_basenames
|
|
21
|
+
paths = Wheneverd::Systemd::UnitLister.list(identifier: identifier_value, unit_dir: unit_dir)
|
|
22
|
+
paths.map { |path| File.basename(path) }.grep(/\.timer\z/).uniq
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def print_list_timers(timer_units)
|
|
26
|
+
stdout, = Wheneverd::Systemd::Systemctl.run("list-timers", "--all", *timer_units)
|
|
27
|
+
print stdout
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def print_status(timer_units)
|
|
31
|
+
timer_units.each do |unit|
|
|
32
|
+
stdout, = Wheneverd::Systemd::Systemctl.run("status", unit)
|
|
33
|
+
print stdout
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
|
|
5
|
+
module Wheneverd
|
|
6
|
+
# Implements `wheneverd validate` (validate rendered OnCalendar values and unit files).
|
|
7
|
+
class CLI::Validate < CLI
|
|
8
|
+
option "--verify", :flag,
|
|
9
|
+
"Also run systemd-analyze --user verify (writes units to a temporary directory)"
|
|
10
|
+
|
|
11
|
+
def execute
|
|
12
|
+
units = render_units
|
|
13
|
+
validate_on_calendar(units)
|
|
14
|
+
validate_units(units) if verify?
|
|
15
|
+
0
|
|
16
|
+
rescue StandardError => e
|
|
17
|
+
handle_error(e)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def validate_on_calendar(units)
|
|
23
|
+
values = on_calendar_values(units)
|
|
24
|
+
if values.empty?
|
|
25
|
+
puts "No OnCalendar= values found" if verbose?
|
|
26
|
+
return
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
values.each do |value|
|
|
30
|
+
Wheneverd::Systemd::Analyze.calendar(value)
|
|
31
|
+
puts "OK OnCalendar=#{value}" if verbose?
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def on_calendar_values(units)
|
|
36
|
+
units.select { |unit| unit.kind == :timer }
|
|
37
|
+
.flat_map { |timer| timer.contents.to_s.lines.grep(/\AOnCalendar=/) }
|
|
38
|
+
.map { |line| line.delete_prefix("OnCalendar=").strip }
|
|
39
|
+
.uniq
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def validate_units(units)
|
|
43
|
+
Dir.mktmpdir("wheneverd-validate-") do |dir|
|
|
44
|
+
paths = Wheneverd::Systemd::UnitWriter.write(units, unit_dir: dir, prune: false)
|
|
45
|
+
Wheneverd::Systemd::Analyze.verify(paths, user: true)
|
|
46
|
+
puts "OK systemd-analyze --user verify" if verbose?
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/wheneverd/cli.rb
CHANGED
|
@@ -63,6 +63,9 @@ end
|
|
|
63
63
|
require_relative "cli/help"
|
|
64
64
|
require_relative "cli/init"
|
|
65
65
|
require_relative "cli/show"
|
|
66
|
+
require_relative "cli/status"
|
|
67
|
+
require_relative "cli/diff"
|
|
68
|
+
require_relative "cli/validate"
|
|
66
69
|
require_relative "cli/write"
|
|
67
70
|
require_relative "cli/delete"
|
|
68
71
|
require_relative "cli/activate"
|
|
@@ -78,6 +81,9 @@ module Wheneverd
|
|
|
78
81
|
subcommand "help", "Show help", Wheneverd::CLI::Help
|
|
79
82
|
subcommand "init", "Create a schedule template", Wheneverd::CLI::Init
|
|
80
83
|
subcommand "show", "Render units to stdout", Wheneverd::CLI::Show
|
|
84
|
+
subcommand "status", "Show systemctl list-timers + status for this identifier", Wheneverd::CLI::Status
|
|
85
|
+
subcommand "diff", "Diff rendered units vs files on disk", Wheneverd::CLI::Diff
|
|
86
|
+
subcommand "validate", "Validate schedule via systemd-analyze", Wheneverd::CLI::Validate
|
|
81
87
|
subcommand "write", "Write units to disk", Wheneverd::CLI::Write
|
|
82
88
|
subcommand "delete", "Delete units from disk", Wheneverd::CLI::Delete
|
|
83
89
|
subcommand "activate", "Enable and start timers via systemctl --user", Wheneverd::CLI::Activate
|
|
@@ -5,7 +5,7 @@ module Wheneverd
|
|
|
5
5
|
# The evaluation context used for schedule files.
|
|
6
6
|
#
|
|
7
7
|
# The schedule file is evaluated via `instance_eval`, so methods defined here become available
|
|
8
|
-
# as the schedule DSL (`every`, `command`).
|
|
8
|
+
# as the schedule DSL (`every`, `command`, `shell`).
|
|
9
9
|
class Context
|
|
10
10
|
# @return [String] absolute schedule path
|
|
11
11
|
attr_reader :path
|
|
@@ -44,21 +44,64 @@ module Wheneverd
|
|
|
44
44
|
|
|
45
45
|
# Add a oneshot command job to the current `every` entry.
|
|
46
46
|
#
|
|
47
|
-
# @
|
|
47
|
+
# @example String command
|
|
48
|
+
# command "echo hello"
|
|
49
|
+
#
|
|
50
|
+
# @example argv command
|
|
51
|
+
# command ["echo", "hello world"]
|
|
52
|
+
#
|
|
53
|
+
# @param command_value [String, Array<String>]
|
|
48
54
|
# @return [void]
|
|
49
|
-
def command(
|
|
50
|
-
|
|
51
|
-
raise LoadError.new("command() must be called inside every() block",
|
|
52
|
-
path: path)
|
|
53
|
-
end
|
|
55
|
+
def command(command_value)
|
|
56
|
+
ensure_in_every_block!("command")
|
|
54
57
|
|
|
55
|
-
@current_entry.add_job(Wheneverd::Job::Command.new(command:
|
|
58
|
+
@current_entry.add_job(Wheneverd::Job::Command.new(command: command_value))
|
|
56
59
|
rescue Wheneverd::InvalidCommandError => e
|
|
57
60
|
raise LoadError.new(e.message, path: path)
|
|
58
61
|
end
|
|
59
62
|
|
|
63
|
+
# Add a oneshot command job that runs via `/bin/bash -lc`.
|
|
64
|
+
#
|
|
65
|
+
# @example
|
|
66
|
+
# shell "echo hello | sed -e s/hello/hi/"
|
|
67
|
+
#
|
|
68
|
+
# @param script [String] non-empty script to pass as `bash -lc <script>`
|
|
69
|
+
# @param shell [String] shell executable (default: "/bin/bash")
|
|
70
|
+
# @return [void]
|
|
71
|
+
def shell(script, shell: "/bin/bash")
|
|
72
|
+
ensure_in_every_block!("shell")
|
|
73
|
+
script_stripped = normalize_shell_script(script)
|
|
74
|
+
shell_executable = normalize_shell_executable(shell)
|
|
75
|
+
command([shell_executable, "-lc", script_stripped])
|
|
76
|
+
end
|
|
77
|
+
|
|
60
78
|
private
|
|
61
79
|
|
|
80
|
+
def ensure_in_every_block!(name)
|
|
81
|
+
return if @current_entry
|
|
82
|
+
|
|
83
|
+
raise LoadError.new("#{name}() must be called inside every() block", path: path)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def normalize_shell_script(script)
|
|
87
|
+
unless script.is_a?(String)
|
|
88
|
+
raise LoadError.new("shell() script must be a String (got #{script.class})",
|
|
89
|
+
path: path)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
stripped = script.strip
|
|
93
|
+
raise LoadError.new("shell() script must not be empty", path: path) if stripped.empty?
|
|
94
|
+
|
|
95
|
+
stripped
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def normalize_shell_executable(shell)
|
|
99
|
+
stripped = shell.to_s.strip
|
|
100
|
+
raise LoadError.new("shell() shell must not be empty", path: path) if stripped.empty?
|
|
101
|
+
|
|
102
|
+
stripped
|
|
103
|
+
end
|
|
104
|
+
|
|
62
105
|
def with_current_entry(entry)
|
|
63
106
|
previous_entry = @current_entry
|
|
64
107
|
@current_entry = entry
|
|
@@ -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
|
data/lib/wheneverd/version.rb
CHANGED
data/lib/wheneverd.rb
CHANGED
|
@@ -35,6 +35,7 @@ require_relative "wheneverd/systemd/cron_parser"
|
|
|
35
35
|
require_relative "wheneverd/systemd/calendar_spec"
|
|
36
36
|
require_relative "wheneverd/systemd/unit_namer"
|
|
37
37
|
require_relative "wheneverd/systemd/renderer"
|
|
38
|
+
require_relative "wheneverd/systemd/analyze"
|
|
38
39
|
require_relative "wheneverd/systemd/systemctl"
|
|
39
40
|
require_relative "wheneverd/systemd/loginctl"
|
|
40
41
|
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
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
require_relative "support/cli_test_helpers"
|
|
5
|
+
|
|
6
|
+
class CLIValidateTest < Minitest::Test
|
|
7
|
+
include CLITestHelpers
|
|
8
|
+
|
|
9
|
+
DUP_HOURLY_SCHEDULE = <<~RUBY
|
|
10
|
+
# frozen_string_literal: true
|
|
11
|
+
|
|
12
|
+
every :hour do
|
|
13
|
+
command "echo a"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
every :hour do
|
|
17
|
+
command "echo b"
|
|
18
|
+
end
|
|
19
|
+
RUBY
|
|
20
|
+
|
|
21
|
+
HOURLY_SCHEDULE = <<~RUBY
|
|
22
|
+
# frozen_string_literal: true
|
|
23
|
+
|
|
24
|
+
every :hour do
|
|
25
|
+
command "echo a"
|
|
26
|
+
end
|
|
27
|
+
RUBY
|
|
28
|
+
|
|
29
|
+
def test_validate_runs_systemd_analyze_calendar_for_each_unique_on_calendar
|
|
30
|
+
with_project_dir do
|
|
31
|
+
write_schedule(DUP_HOURLY_SCHEDULE)
|
|
32
|
+
status, out, err, calls = run_validate("--verbose")
|
|
33
|
+
assert_cli_success(status, err)
|
|
34
|
+
assert_includes out, "OK OnCalendar=hourly"
|
|
35
|
+
assert_equal 1, calls.length
|
|
36
|
+
assert_equal %w[systemd-analyze calendar hourly], calls.fetch(0).fetch(0)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def test_validate_prints_message_when_no_on_calendar_and_verbose
|
|
41
|
+
with_project_dir do
|
|
42
|
+
write_empty_schedule
|
|
43
|
+
status, out, err, calls = run_validate("--verbose")
|
|
44
|
+
assert_cli_success(status, err)
|
|
45
|
+
assert_includes out, "No OnCalendar= values found"
|
|
46
|
+
assert_equal [], calls
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def test_validate_verify_runs_systemd_analyze_verify
|
|
51
|
+
with_project_dir do
|
|
52
|
+
write_schedule(HOURLY_SCHEDULE)
|
|
53
|
+
status, out, err, calls = run_validate("--verify", "--verbose")
|
|
54
|
+
assert_cli_success(status, err)
|
|
55
|
+
assert_includes out, "OK systemd-analyze --user verify"
|
|
56
|
+
assert_equal %w[systemd-analyze --user verify], calls.fetch(1).fetch(0).take(3)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def test_validate_returns_nonzero_when_systemd_analyze_calendar_fails
|
|
61
|
+
with_project_dir do
|
|
62
|
+
write_schedule(HOURLY_SCHEDULE)
|
|
63
|
+
status, _out, err, calls = run_validate(exitstatus: 1, stderr: "boom\n")
|
|
64
|
+
assert_equal 1, status
|
|
65
|
+
assert_includes err, "systemd-analyze failed"
|
|
66
|
+
assert_includes err, "boom"
|
|
67
|
+
assert_equal %w[systemd-analyze calendar hourly], calls.fetch(0).fetch(0)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def run_validate(*args, **kwargs)
|
|
74
|
+
run_cli_with_capture3_stub(["validate", *args], **kwargs)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def write_schedule(contents)
|
|
78
|
+
FileUtils.mkdir_p("config")
|
|
79
|
+
File.write(File.join("config", "schedule.rb"), contents)
|
|
80
|
+
end
|
|
81
|
+
end
|
data/test/domain_model_test.rb
CHANGED
|
@@ -61,7 +61,7 @@ class DomainModelTest < Minitest::Test
|
|
|
61
61
|
assert_raises(Wheneverd::InvalidCommandError) { Wheneverd::Job::Command.new(command: " ") }
|
|
62
62
|
|
|
63
63
|
error = assert_raises(Wheneverd::InvalidCommandError) { Wheneverd::Job::Command.new(command: 123) }
|
|
64
|
-
assert_includes error.message, "String"
|
|
64
|
+
assert_includes error.message, "String or an Array"
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
def test_triggers_render_timer_lines
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class DSLContextShellTest < Minitest::Test
|
|
6
|
+
def test_shell_requires_every_block
|
|
7
|
+
ctx = Wheneverd::DSL::Context.new(path: "/tmp/config/schedule.rb")
|
|
8
|
+
error = assert_raises(Wheneverd::DSL::LoadError) { ctx.shell("echo hi") }
|
|
9
|
+
assert_includes error.message, "shell() must be called inside every() block"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def test_shell_requires_string_script
|
|
13
|
+
ctx = Wheneverd::DSL::Context.new(path: "/tmp/config/schedule.rb")
|
|
14
|
+
error = assert_raises(Wheneverd::DSL::LoadError) { ctx.every("1m") { shell(123) } }
|
|
15
|
+
assert_includes error.message, "shell() script must be a String"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_shell_rejects_empty_script
|
|
19
|
+
ctx = Wheneverd::DSL::Context.new(path: "/tmp/config/schedule.rb")
|
|
20
|
+
error = assert_raises(Wheneverd::DSL::LoadError) { ctx.every("1m") { shell(" ") } }
|
|
21
|
+
assert_includes error.message, "shell() script must not be empty"
|
|
22
|
+
end
|
|
23
|
+
end
|
data/test/dsl_loader_test.rb
CHANGED
|
@@ -45,6 +45,29 @@ class DSLLoaderIntervalAndDurationTest < Minitest::Test
|
|
|
45
45
|
assert_equal ["echo hello"], entry.jobs.map(&:command)
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
def test_loads_argv_command
|
|
49
|
+
schedule = load_schedule(<<~RUBY)
|
|
50
|
+
every "5m" do
|
|
51
|
+
command ["echo", "hello world"]
|
|
52
|
+
end
|
|
53
|
+
RUBY
|
|
54
|
+
|
|
55
|
+
job = schedule.entries.fetch(0).jobs.fetch(0)
|
|
56
|
+
assert_equal ["echo", "hello world"], job.argv
|
|
57
|
+
assert_equal "echo \"hello world\"", job.command
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def test_loads_shell_helper
|
|
61
|
+
schedule = load_schedule(<<~RUBY)
|
|
62
|
+
every "5m" do
|
|
63
|
+
shell "echo hello | sed -e s/hello/hi/"
|
|
64
|
+
end
|
|
65
|
+
RUBY
|
|
66
|
+
|
|
67
|
+
job = schedule.entries.fetch(0).jobs.fetch(0)
|
|
68
|
+
assert_equal ["/bin/bash", "-lc", "echo hello | sed -e s/hello/hi/"], job.argv
|
|
69
|
+
end
|
|
70
|
+
|
|
48
71
|
def test_loads_duration_with_at_as_calendar
|
|
49
72
|
schedule = load_schedule(<<~RUBY)
|
|
50
73
|
every 1.day, at: "4:30 am" do
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class JobCommandTest < Minitest::Test
|
|
6
|
+
def test_accepts_argv_and_formats_execstart
|
|
7
|
+
command = Wheneverd::Job::Command.new(command: ["echo", "hello world"])
|
|
8
|
+
assert_equal ["echo", "hello world"], command.argv
|
|
9
|
+
assert_equal "echo \"hello world\"", command.command
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def test_accepts_empty_argv_argument
|
|
13
|
+
command = Wheneverd::Job::Command.new(command: ["printf", "%s", ""])
|
|
14
|
+
assert_equal "printf %s \"\"", command.command
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def test_rejects_invalid_argv
|
|
18
|
+
assert_raises(Wheneverd::InvalidCommandError) { Wheneverd::Job::Command.new(command: []) }
|
|
19
|
+
assert_raises(Wheneverd::InvalidCommandError) { Wheneverd::Job::Command.new(command: [" "]) }
|
|
20
|
+
assert_raises(Wheneverd::InvalidCommandError) { Wheneverd::Job::Command.new(command: ["echo", 1]) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def test_rejects_argv_with_newlines
|
|
24
|
+
error = assert_raises(Wheneverd::InvalidCommandError) do
|
|
25
|
+
Wheneverd::Job::Command.new(command: %W[echo hi\nthere])
|
|
26
|
+
end
|
|
27
|
+
assert_includes error.message, "must not include NUL or newlines"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class SystemdAnalyzeTest < Minitest::Test
|
|
6
|
+
def with_capture3_stub(exitstatus:, stdout: "", stderr: "")
|
|
7
|
+
calls = []
|
|
8
|
+
Thread.current[:open3_capture3_stub] = {
|
|
9
|
+
calls: calls,
|
|
10
|
+
stdout: stdout,
|
|
11
|
+
stderr: stderr,
|
|
12
|
+
exitstatus: exitstatus
|
|
13
|
+
}
|
|
14
|
+
yield calls
|
|
15
|
+
ensure
|
|
16
|
+
Thread.current[:open3_capture3_stub] = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def test_calendar_builds_command_and_returns_output
|
|
20
|
+
with_capture3_stub(exitstatus: 0, stdout: "ok\n", stderr: "") do |calls|
|
|
21
|
+
out, err = Wheneverd::Systemd::Analyze.calendar("hourly")
|
|
22
|
+
assert_equal "ok\n", out
|
|
23
|
+
assert_equal "", err
|
|
24
|
+
assert_equal [%w[systemd-analyze calendar hourly], {}], calls.fetch(0)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def test_verify_builds_user_verify_command
|
|
29
|
+
with_capture3_stub(exitstatus: 0) do |calls|
|
|
30
|
+
Wheneverd::Systemd::Analyze.verify(["/tmp/a.timer", "/tmp/a.service"], user: true)
|
|
31
|
+
assert_equal(
|
|
32
|
+
[["systemd-analyze", "--user", "verify", "/tmp/a.timer", "/tmp/a.service"], {}],
|
|
33
|
+
calls.fetch(0)
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def test_calendar_raises_systemd_analyze_error_on_failure_with_details
|
|
39
|
+
with_capture3_stub(exitstatus: 1, stdout: "oops\n", stderr: "nope\n") do
|
|
40
|
+
error = assert_raises(Wheneverd::Systemd::SystemdAnalyzeError) do
|
|
41
|
+
Wheneverd::Systemd::Analyze.calendar("hourly")
|
|
42
|
+
end
|
|
43
|
+
assert_includes error.message, "status: 1"
|
|
44
|
+
assert_includes error.message, "stdout: oops"
|
|
45
|
+
assert_includes error.message, "stderr: nope"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def test_calendar_raises_systemd_analyze_error_when_missing
|
|
50
|
+
error = assert_raises(Wheneverd::Systemd::SystemdAnalyzeError) do
|
|
51
|
+
Wheneverd::Systemd::Analyze.calendar("hourly", systemd_analyze: "/no/such/systemd-analyze")
|
|
52
|
+
end
|
|
53
|
+
assert_includes error.message, "systemd-analyze not found"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -62,6 +62,12 @@ class SystemdRendererIntervalTest < Minitest::Test
|
|
|
62
62
|
assert_includes service.contents, "Type=oneshot"
|
|
63
63
|
assert_includes service.contents, "ExecStart=echo hello"
|
|
64
64
|
end
|
|
65
|
+
|
|
66
|
+
def test_interval_service_contains_execstart_for_argv
|
|
67
|
+
entry = interval_entry(seconds: 60, command: ["echo", "hello world"])
|
|
68
|
+
service = service_for(entry)
|
|
69
|
+
assert_includes service.contents, "ExecStart=echo \"hello world\""
|
|
70
|
+
end
|
|
65
71
|
end
|
|
66
72
|
|
|
67
73
|
class SystemdRendererCalendarTest < Minitest::Test
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: wheneverd
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- bigcurl
|
|
@@ -50,11 +50,14 @@ files:
|
|
|
50
50
|
- lib/wheneverd/cli/current.rb
|
|
51
51
|
- lib/wheneverd/cli/deactivate.rb
|
|
52
52
|
- lib/wheneverd/cli/delete.rb
|
|
53
|
+
- lib/wheneverd/cli/diff.rb
|
|
53
54
|
- lib/wheneverd/cli/help.rb
|
|
54
55
|
- lib/wheneverd/cli/init.rb
|
|
55
56
|
- lib/wheneverd/cli/linger.rb
|
|
56
57
|
- lib/wheneverd/cli/reload.rb
|
|
57
58
|
- lib/wheneverd/cli/show.rb
|
|
59
|
+
- lib/wheneverd/cli/status.rb
|
|
60
|
+
- lib/wheneverd/cli/validate.rb
|
|
58
61
|
- lib/wheneverd/cli/write.rb
|
|
59
62
|
- lib/wheneverd/core_ext/numeric_duration.rb
|
|
60
63
|
- lib/wheneverd/dsl/at_normalizer.rb
|
|
@@ -69,6 +72,7 @@ files:
|
|
|
69
72
|
- lib/wheneverd/interval.rb
|
|
70
73
|
- lib/wheneverd/job/command.rb
|
|
71
74
|
- lib/wheneverd/schedule.rb
|
|
75
|
+
- lib/wheneverd/systemd/analyze.rb
|
|
72
76
|
- lib/wheneverd/systemd/calendar_spec.rb
|
|
73
77
|
- lib/wheneverd/systemd/cron_parser.rb
|
|
74
78
|
- lib/wheneverd/systemd/errors.rb
|
|
@@ -87,17 +91,24 @@ files:
|
|
|
87
91
|
- test/cli_activate_test.rb
|
|
88
92
|
- test/cli_current_test.rb
|
|
89
93
|
- test/cli_deactivate_test.rb
|
|
94
|
+
- test/cli_diff_test.rb
|
|
90
95
|
- test/cli_end_to_end_test.rb
|
|
96
|
+
- test/cli_init_test.rb
|
|
91
97
|
- test/cli_linger_test.rb
|
|
92
98
|
- test/cli_reload_test.rb
|
|
99
|
+
- test/cli_status_test.rb
|
|
93
100
|
- test/cli_systemctl_integration_test.rb
|
|
94
101
|
- test/cli_systemd_analyze_test.rb
|
|
95
102
|
- test/cli_test.rb
|
|
103
|
+
- test/cli_validate_test.rb
|
|
96
104
|
- test/domain_model_test.rb
|
|
97
105
|
- test/dsl_calendar_symbol_period_list_test.rb
|
|
106
|
+
- test/dsl_context_shell_test.rb
|
|
98
107
|
- test/dsl_loader_test.rb
|
|
108
|
+
- test/job_command_test.rb
|
|
99
109
|
- test/support/cli_subprocess_test_helpers.rb
|
|
100
110
|
- test/support/cli_test_helpers.rb
|
|
111
|
+
- test/systemd_analyze_test.rb
|
|
101
112
|
- test/systemd_calendar_spec_test.rb
|
|
102
113
|
- test/systemd_cron_parser_test.rb
|
|
103
114
|
- test/systemd_renderer_errors_test.rb
|
|
@@ -131,7 +142,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
131
142
|
- !ruby/object:Gem::Version
|
|
132
143
|
version: '0'
|
|
133
144
|
requirements: []
|
|
134
|
-
rubygems_version: 4.0.
|
|
145
|
+
rubygems_version: 4.0.4
|
|
135
146
|
specification_version: 4
|
|
136
147
|
summary: Wheneverd is to systemd timers what whenever is to cron.
|
|
137
148
|
test_files: []
|