wheneverd 0.2.0 → 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 +13 -1
- data/FEATURE_SUMMARY.md +14 -3
- data/Gemfile.lock +2 -2
- data/README.md +38 -16
- 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 +53 -11
- data/lib/wheneverd/entry.rb +2 -4
- 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,10 +5,22 @@ On release, entries are moved into `## x.y.z` sections that match the gem versio
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
-
|
|
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
|
+
|
|
14
|
+
## 0.2.1
|
|
15
|
+
|
|
16
|
+
- Removes an unused filtering metadata keyword argument from the schedule DSL.
|
|
9
17
|
|
|
10
18
|
## 0.2.0
|
|
11
19
|
|
|
20
|
+
- Adds `wheneverd linger enable|disable|status` for managing systemd user lingering via `loginctl`.
|
|
21
|
+
|
|
22
|
+
## 0.1.0
|
|
23
|
+
|
|
12
24
|
- Provides a Clamp-based `wheneverd` CLI with `--help`, `--version`, and `--verbose` (usage errors in `ERROR: ...` format).
|
|
13
25
|
- Adds core domain objects and helpers for building schedules (interval parsing, durations, triggers, entries, jobs).
|
|
14
26
|
- Adds a Ruby DSL loader (`Wheneverd::DSL::Loader.load_file`) supporting `every(...)` blocks with `command(...)` jobs.
|
data/FEATURE_SUMMARY.md
CHANGED
|
@@ -12,19 +12,30 @@ It complements [`CHANGELOG.md`](CHANGELOG.md) by staying high-level and focusing
|
|
|
12
12
|
|
|
13
13
|
## Unreleased
|
|
14
14
|
|
|
15
|
-
|
|
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
|
+
|
|
21
|
+
## 0.2.1
|
|
22
|
+
|
|
23
|
+
- Removes an unused filtering metadata keyword argument from the schedule DSL.
|
|
16
24
|
|
|
17
25
|
## 0.2.0
|
|
18
26
|
|
|
27
|
+
- Adds `wheneverd linger enable|disable|status` for managing systemd user lingering via `loginctl`.
|
|
28
|
+
|
|
29
|
+
## 0.1.0
|
|
30
|
+
|
|
19
31
|
- The `wheneverd` CLI is implemented using Clamp (`--help`, usage errors in `ERROR: ...` format, `--verbose` for details).
|
|
20
32
|
- The gem includes a small “whenever-like” domain model (interval parsing, durations, triggers, schedules).
|
|
21
33
|
- The gem can load a Ruby schedule DSL file via `Wheneverd::DSL::Loader.load_file`.
|
|
22
|
-
- Schedule DSL supports `every(period, at:
|
|
34
|
+
- Schedule DSL supports `every(period, at: ...) { command("...") }` entries (multiple `command` calls per entry).
|
|
23
35
|
- Schedule DSL supports multiple calendar period symbols per `every` block (e.g. `every :tuesday, :wednesday`).
|
|
24
36
|
- Supported `every` periods include interval strings/durations, calendar shortcuts (`:hour`, `:day`, `:month`, `:year`),
|
|
25
37
|
day selectors (`:monday..:sunday`, `:weekday`, `:weekend`), and standard 5-field cron strings.
|
|
26
38
|
- `at:` supports a string or an array of strings (for calendar schedules), like `"4:30 am"` or `"00:15"`.
|
|
27
|
-
- `roles:` is accepted and stored on entries, but is not used for filtering yet.
|
|
28
39
|
- The gem can render systemd `.service` and `.timer` units via `Wheneverd::Systemd::Renderer.render`.
|
|
29
40
|
- Generated unit basenames include a stable ID derived from the job’s trigger + command (reordering schedule blocks won’t rename units).
|
|
30
41
|
- Interval timers include both `OnActiveSec=` and `OnUnitActiveSec=` to ensure a newly started timer has a next run scheduled.
|
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
|
@@ -2,15 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
Wheneverd is to systemd timers what the [`whenever` gem](https://github.com/javan/whenever) is to cron.
|
|
4
4
|
|
|
5
|
-
Tagline / repo: `git@github.com:bigcurl/wheneverd.git`
|
|
6
|
-
|
|
7
5
|
## Status
|
|
8
6
|
|
|
9
|
-
|
|
7
|
+
Pre-1.0, but working end-to-end for user systemd timers:
|
|
10
8
|
|
|
11
|
-
|
|
9
|
+
- Loads a Ruby schedule DSL file (default: `config/schedule.rb`).
|
|
10
|
+
- Renders systemd `.service`/`.timer` units (interval, calendar, and 5-field cron schedules).
|
|
11
|
+
- Writes, lists, and deletes generated unit files (default: `~/.config/systemd/user`).
|
|
12
|
+
- Enables/starts/stops/disables/restarts timers via `systemctl --user`.
|
|
13
|
+
- Manages lingering via `loginctl` (so timers can run while logged out).
|
|
12
14
|
|
|
13
|
-
See `FEATURE_SUMMARY.md` for
|
|
15
|
+
See `FEATURE_SUMMARY.md` for user-visible behavior, and `CHANGELOG.md` for release notes.
|
|
14
16
|
|
|
15
17
|
## Installation
|
|
16
18
|
|
|
@@ -32,15 +34,20 @@ bundle install
|
|
|
32
34
|
wheneverd --help
|
|
33
35
|
wheneverd init
|
|
34
36
|
wheneverd show
|
|
37
|
+
wheneverd status
|
|
38
|
+
wheneverd diff
|
|
39
|
+
wheneverd validate
|
|
35
40
|
wheneverd write
|
|
36
41
|
wheneverd delete
|
|
37
42
|
wheneverd activate
|
|
38
43
|
wheneverd deactivate
|
|
39
44
|
wheneverd reload
|
|
40
45
|
wheneverd current
|
|
41
|
-
wheneverd linger
|
|
46
|
+
wheneverd linger
|
|
42
47
|
```
|
|
43
48
|
|
|
49
|
+
Use `wheneverd init` to create a starter `config/schedule.rb` template (including examples for `command` and `shell`).
|
|
50
|
+
|
|
44
51
|
### Minimal `config/schedule.rb` example
|
|
45
52
|
|
|
46
53
|
```ruby
|
|
@@ -120,7 +127,7 @@ Note: schedule files are executed as Ruby. Do not run untrusted schedule code.
|
|
|
120
127
|
The core shape is:
|
|
121
128
|
|
|
122
129
|
```ruby
|
|
123
|
-
every(period, at: nil
|
|
130
|
+
every(period, at: nil) do
|
|
124
131
|
command "echo hello"
|
|
125
132
|
end
|
|
126
133
|
```
|
|
@@ -135,13 +142,26 @@ end
|
|
|
135
142
|
|
|
136
143
|
### `command`
|
|
137
144
|
|
|
138
|
-
`command(
|
|
145
|
+
`command(...)` appends a oneshot `ExecStart=` job.
|
|
139
146
|
|
|
140
|
-
|
|
141
|
-
|
|
147
|
+
Accepted forms:
|
|
148
|
+
|
|
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`:
|
|
142
153
|
|
|
143
154
|
```ruby
|
|
144
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/"
|
|
145
165
|
```
|
|
146
166
|
|
|
147
167
|
### `every` periods
|
|
@@ -185,10 +205,6 @@ Cron translation supports standard 5-field crontab strings (`minute hour day-of-
|
|
|
185
205
|
|
|
186
206
|
Unsupported cron patterns raise an error at render time (e.g. non-5-field strings, `@daily`, `L`, `W`, `#`, `?`).
|
|
187
207
|
|
|
188
|
-
### `roles:`
|
|
189
|
-
|
|
190
|
-
`roles:` is accepted and stored on entries, but is ignored in v1 (no role-based filtering yet).
|
|
191
|
-
|
|
192
208
|
## CLI
|
|
193
209
|
|
|
194
210
|
Defaults:
|
|
@@ -204,17 +220,23 @@ Notes:
|
|
|
204
220
|
- Identifiers are sanitized for use in unit file names (non-alphanumeric characters become `-`).
|
|
205
221
|
- Unit basenames include a stable ID derived from the job’s trigger + command (reordering schedule blocks won’t rename units).
|
|
206
222
|
- `wheneverd write` / `wheneverd reload` prune previously generated units for the identifier by default (use `--no-prune` to keep old units around).
|
|
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.
|
|
207
225
|
|
|
208
226
|
Commands:
|
|
209
227
|
|
|
210
228
|
- `wheneverd init [--schedule PATH] [--force]` writes a template schedule file.
|
|
211
229
|
- `wheneverd show [--schedule PATH] [--identifier NAME]` prints rendered units to stdout.
|
|
212
|
-
- `wheneverd
|
|
213
|
-
- `wheneverd
|
|
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).
|
|
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).
|
|
234
|
+
- `wheneverd delete [--identifier NAME] [--unit-dir PATH] [--dry-run]` deletes previously generated units for the identifier.
|
|
214
235
|
- `wheneverd activate [--schedule PATH] [--identifier NAME]` runs `systemctl --user daemon-reload` and enables/starts the timers.
|
|
215
236
|
- `wheneverd deactivate [--schedule PATH] [--identifier NAME]` stops and disables the timers.
|
|
216
237
|
- `wheneverd reload [--schedule PATH] [--identifier NAME] [--unit-dir PATH] [--[no-]prune]` writes units, reloads systemd, and restarts timers.
|
|
217
238
|
- `wheneverd current [--identifier NAME] [--unit-dir PATH]` prints the currently installed unit file contents from disk.
|
|
239
|
+
- `wheneverd linger [--user NAME] [enable|disable|status]` manages lingering via `loginctl` (`status` is the default).
|
|
218
240
|
|
|
219
241
|
## Development
|
|
220
242
|
|
|
@@ -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
|
|
@@ -25,16 +25,15 @@ module Wheneverd
|
|
|
25
25
|
#
|
|
26
26
|
# @param periods [Array<String, Symbol, Wheneverd::Duration, Array<Symbol>>]
|
|
27
27
|
# @param at [String, Array<String>, nil]
|
|
28
|
-
# @param roles [Object] stored but currently not used for filtering
|
|
29
28
|
# @return [Wheneverd::Entry]
|
|
30
|
-
def every(*periods, at: nil,
|
|
29
|
+
def every(*periods, at: nil, &block)
|
|
31
30
|
raise InvalidPeriodError.new("every() requires a block", path: path) unless block
|
|
32
31
|
|
|
33
32
|
raise InvalidPeriodError.new("every() requires a period", path: path) if periods.empty?
|
|
34
33
|
|
|
35
34
|
period = periods.length == 1 ? periods.first : periods
|
|
36
35
|
trigger = @period_parser.trigger_for(period, at: at)
|
|
37
|
-
entry = Wheneverd::Entry.new(trigger: trigger
|
|
36
|
+
entry = Wheneverd::Entry.new(trigger: trigger)
|
|
38
37
|
|
|
39
38
|
schedule.add_entry(entry)
|
|
40
39
|
|
|
@@ -45,21 +44,64 @@ module Wheneverd
|
|
|
45
44
|
|
|
46
45
|
# Add a oneshot command job to the current `every` entry.
|
|
47
46
|
#
|
|
48
|
-
# @
|
|
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>]
|
|
49
54
|
# @return [void]
|
|
50
|
-
def command(
|
|
51
|
-
|
|
52
|
-
raise LoadError.new("command() must be called inside every() block",
|
|
53
|
-
path: path)
|
|
54
|
-
end
|
|
55
|
+
def command(command_value)
|
|
56
|
+
ensure_in_every_block!("command")
|
|
55
57
|
|
|
56
|
-
@current_entry.add_job(Wheneverd::Job::Command.new(command:
|
|
58
|
+
@current_entry.add_job(Wheneverd::Job::Command.new(command: command_value))
|
|
57
59
|
rescue Wheneverd::InvalidCommandError => e
|
|
58
60
|
raise LoadError.new(e.message, path: path)
|
|
59
61
|
end
|
|
60
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
|
+
|
|
61
78
|
private
|
|
62
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
|
+
|
|
63
105
|
def with_current_entry(entry)
|
|
64
106
|
previous_entry = @current_entry
|
|
65
107
|
@current_entry = entry
|
data/lib/wheneverd/entry.rb
CHANGED
|
@@ -6,17 +6,15 @@ module Wheneverd
|
|
|
6
6
|
# An entry ties together a trigger (when to run) and one or more jobs (what to run).
|
|
7
7
|
class Entry
|
|
8
8
|
# @return [Wheneverd::Trigger::Interval, Wheneverd::Trigger::Calendar, Wheneverd::Trigger::Boot]
|
|
9
|
-
attr_reader :trigger, :jobs
|
|
9
|
+
attr_reader :trigger, :jobs
|
|
10
10
|
|
|
11
11
|
# @param trigger [Object] a trigger object describing when to run
|
|
12
12
|
# @param jobs [Array<Object>] job objects (usually {Wheneverd::Job::Command})
|
|
13
|
-
|
|
14
|
-
def initialize(trigger:, jobs: [], roles: nil)
|
|
13
|
+
def initialize(trigger:, jobs: [])
|
|
15
14
|
raise ArgumentError, "trigger is required" if trigger.nil?
|
|
16
15
|
|
|
17
16
|
@trigger = trigger
|
|
18
17
|
@jobs = jobs.dup
|
|
19
|
-
@roles = roles
|
|
20
18
|
end
|
|
21
19
|
|
|
22
20
|
# Append a job to the 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
|