wheneverd 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -0
- data/FEATURE_SUMMARY.md +10 -1
- data/Gemfile.lock +2 -2
- data/README.md +70 -5
- data/lib/wheneverd/cli/diff.rb +98 -0
- data/lib/wheneverd/cli/init.rb +5 -1
- data/lib/wheneverd/cli/status.rb +37 -0
- data/lib/wheneverd/cli/validate.rb +50 -0
- data/lib/wheneverd/cli.rb +6 -0
- data/lib/wheneverd/dsl/context.rb +51 -8
- data/lib/wheneverd/dsl/period_parser.rb +8 -107
- data/lib/wheneverd/dsl/period_strategy/array_strategy.rb +29 -0
- data/lib/wheneverd/dsl/period_strategy/base.rb +65 -0
- data/lib/wheneverd/dsl/period_strategy/duration_strategy.rb +33 -0
- data/lib/wheneverd/dsl/period_strategy/string_strategy.rb +51 -0
- data/lib/wheneverd/dsl/period_strategy/symbol_strategy.rb +31 -0
- data/lib/wheneverd/dsl/period_strategy.rb +43 -0
- data/lib/wheneverd/duration.rb +1 -7
- data/lib/wheneverd/errors.rb +3 -0
- data/lib/wheneverd/interval.rb +22 -7
- data/lib/wheneverd/job/command.rb +88 -8
- data/lib/wheneverd/systemd/analyze.rb +56 -0
- data/lib/wheneverd/systemd/cron_parser/dow_parser.rb +208 -0
- data/lib/wheneverd/systemd/cron_parser/field_parser.rb +163 -0
- data/lib/wheneverd/systemd/cron_parser.rb +56 -303
- data/lib/wheneverd/systemd/errors.rb +3 -0
- data/lib/wheneverd/systemd/renderer.rb +6 -64
- data/lib/wheneverd/systemd/unit_content_builder.rb +76 -0
- data/lib/wheneverd/systemd/unit_deleter.rb +2 -28
- data/lib/wheneverd/systemd/unit_lister.rb +2 -28
- data/lib/wheneverd/systemd/unit_namer.rb +6 -14
- data/lib/wheneverd/systemd/unit_path_utils.rb +54 -0
- data/lib/wheneverd/systemd/unit_writer.rb +2 -28
- data/lib/wheneverd/trigger/base.rb +22 -0
- data/lib/wheneverd/trigger/boot.rb +8 -6
- data/lib/wheneverd/trigger/calendar.rb +7 -0
- data/lib/wheneverd/trigger/interval.rb +8 -6
- data/lib/wheneverd/validation.rb +89 -0
- data/lib/wheneverd/version.rb +1 -1
- data/lib/wheneverd.rb +5 -1
- data/test/cli_diff_test.rb +118 -0
- data/test/cli_init_test.rb +49 -0
- data/test/cli_status_test.rb +76 -0
- data/test/cli_validate_test.rb +81 -0
- data/test/domain_model_test.rb +106 -1
- data/test/dsl_context_shell_test.rb +23 -0
- data/test/dsl_loader_test.rb +23 -0
- data/test/job_command_test.rb +29 -0
- data/test/systemd_analyze_test.rb +55 -0
- data/test/systemd_cron_parser_test.rb +41 -25
- data/test/systemd_renderer_errors_test.rb +1 -1
- data/test/systemd_renderer_test.rb +6 -0
- metadata +25 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ff946dbc0ba39d3e90ecf3cbed4c9ea81c1818fcd1d206f00cf6763dfe05ebe1
|
|
4
|
+
data.tar.gz: cd42856421c97365ff63547252ea4e8ba8a5f17dc822eea8af67b45ce8861b8f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a4d31fc560c96e1b10b7b42cb0c32e4aa222823e64bde6a17cacb891246d67caefb9e42b5f71ce23a26b14392c33eb819f42fc3e199a0b2f10a0f040bb5e5193
|
|
7
|
+
data.tar.gz: fc2e7a43ed41af1b7b00cb4cc8a228054071cec67ca53bb81829134ab884f8a6b5730796a699bdd6066262d3fee18ff0234f535d2fe967b5d26543d2d7b2097e
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +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
|
+
## 0.4.0
|
|
9
|
+
|
|
10
|
+
- Docs: adds a copy/paste "deploy a simple schedule" example and refines README status section.
|
|
11
|
+
- Refactor: extracts `UnitPathUtils` module for shared identifier/path utilities across `UnitWriter`, `UnitDeleter`, `UnitLister`, and `Renderer`.
|
|
12
|
+
- Refactor: adds polymorphic `Trigger::Base` interface with `#systemd_timer_lines` and `#signature` methods for all trigger types.
|
|
13
|
+
- Refactor: splits `CronParser` into focused `FieldParser` and `DowParser` submodules for maintainability.
|
|
14
|
+
- Refactor: implements strategy pattern for `PeriodParser` with dedicated strategies for Duration, String, Symbol, and Array inputs.
|
|
15
|
+
- Refactor: extracts `UnitContentBuilder` from `Renderer` for cleaner separation of unit content generation.
|
|
16
|
+
- Refactor: adds `Validation` module with composable validators (`type`, `positive_integer`, `non_empty_string`, `non_empty_array`, `in_range`).
|
|
17
|
+
|
|
18
|
+
## 0.3.0
|
|
19
|
+
|
|
20
|
+
- Schedule DSL: `command` accepts argv arrays, adds a `shell` helper for `/bin/bash -lc`, and `wheneverd init` includes examples.
|
|
21
|
+
- Adds `wheneverd status` (show `systemctl --user list-timers` + `systemctl --user status` for installed timers) and `wheneverd diff` (diff rendered units vs files on disk).
|
|
22
|
+
- Adds `wheneverd validate` to validate rendered `OnCalendar=` values via `systemd-analyze calendar` (and with `--verify`, runs `systemd-analyze --user verify` on temporary unit files).
|
|
23
|
+
|
|
8
24
|
## 0.2.1
|
|
9
25
|
|
|
10
26
|
- 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
|
@@ -4,14 +4,23 @@ Wheneverd is to systemd timers what the [`whenever` gem](https://github.com/java
|
|
|
4
4
|
|
|
5
5
|
## Status
|
|
6
6
|
|
|
7
|
-
Pre-1.0, but working end-to-end for user
|
|
7
|
+
Pre-1.0, but working end-to-end for systemd user timers on Linux:
|
|
8
8
|
|
|
9
9
|
- Loads a Ruby schedule DSL file (default: `config/schedule.rb`).
|
|
10
10
|
- Renders systemd `.service`/`.timer` units (interval, calendar, and 5-field cron schedules).
|
|
11
|
-
- Writes,
|
|
11
|
+
- Writes, diffs, shows, and deletes generated unit files (default: `~/.config/systemd/user`).
|
|
12
12
|
- Enables/starts/stops/disables/restarts timers via `systemctl --user`.
|
|
13
|
+
- Validates `OnCalendar=` values with `systemd-analyze` (optional unit verification).
|
|
13
14
|
- Manages lingering via `loginctl` (so timers can run while logged out).
|
|
14
15
|
|
|
16
|
+
Non-goals / not yet implemented:
|
|
17
|
+
|
|
18
|
+
- System-level units (`/etc/systemd/system`) / `systemctl` without `--user`.
|
|
19
|
+
- Non-systemd schedulers (cron, launchd, etc).
|
|
20
|
+
- Non-Linux platforms (no Windows/macOS support).
|
|
21
|
+
|
|
22
|
+
Expect the CLI and generated unit details to change until 1.0.
|
|
23
|
+
|
|
15
24
|
See `FEATURE_SUMMARY.md` for user-visible behavior, and `CHANGELOG.md` for release notes.
|
|
16
25
|
|
|
17
26
|
## Installation
|
|
@@ -34,6 +43,9 @@ bundle install
|
|
|
34
43
|
wheneverd --help
|
|
35
44
|
wheneverd init
|
|
36
45
|
wheneverd show
|
|
46
|
+
wheneverd status
|
|
47
|
+
wheneverd diff
|
|
48
|
+
wheneverd validate
|
|
37
49
|
wheneverd write
|
|
38
50
|
wheneverd delete
|
|
39
51
|
wheneverd activate
|
|
@@ -43,6 +55,8 @@ wheneverd current
|
|
|
43
55
|
wheneverd linger
|
|
44
56
|
```
|
|
45
57
|
|
|
58
|
+
Use `wheneverd init` to create a starter `config/schedule.rb` template (including examples for `command` and `shell`).
|
|
59
|
+
|
|
46
60
|
### Minimal `config/schedule.rb` example
|
|
47
61
|
|
|
48
62
|
```ruby
|
|
@@ -57,6 +71,40 @@ every 1.day, at: "4:30 am" do
|
|
|
57
71
|
end
|
|
58
72
|
```
|
|
59
73
|
|
|
74
|
+
### Deploy a simple schedule (copy/paste)
|
|
75
|
+
|
|
76
|
+
From your project root (the default identifier is the current directory name):
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
# Install (skip if already in your Gemfile)
|
|
80
|
+
bundle add wheneverd
|
|
81
|
+
bundle install
|
|
82
|
+
|
|
83
|
+
# Write a schedule that appends a timestamp to ~/.cache/wheneverd-demo.log every minute
|
|
84
|
+
mkdir -p config
|
|
85
|
+
cat > config/schedule.rb <<'RUBY'
|
|
86
|
+
# frozen_string_literal: true
|
|
87
|
+
|
|
88
|
+
every "1m" do
|
|
89
|
+
shell "mkdir -p ~/.cache && date >> ~/.cache/wheneverd-demo.log"
|
|
90
|
+
end
|
|
91
|
+
RUBY
|
|
92
|
+
|
|
93
|
+
# Preview, write units, and enable/start the timer(s)
|
|
94
|
+
bundle exec wheneverd show
|
|
95
|
+
bundle exec wheneverd validate
|
|
96
|
+
bundle exec wheneverd write
|
|
97
|
+
bundle exec wheneverd activate
|
|
98
|
+
|
|
99
|
+
# Verify it’s installed and running
|
|
100
|
+
bundle exec wheneverd status
|
|
101
|
+
tail -n 5 ~/.cache/wheneverd-demo.log
|
|
102
|
+
|
|
103
|
+
# Stop/disable timers and remove generated unit files
|
|
104
|
+
bundle exec wheneverd deactivate
|
|
105
|
+
bundle exec wheneverd delete
|
|
106
|
+
```
|
|
107
|
+
|
|
60
108
|
Preview the generated units:
|
|
61
109
|
|
|
62
110
|
```bash
|
|
@@ -137,13 +185,26 @@ end
|
|
|
137
185
|
|
|
138
186
|
### `command`
|
|
139
187
|
|
|
140
|
-
`command(
|
|
188
|
+
`command(...)` appends a oneshot `ExecStart=` job.
|
|
189
|
+
|
|
190
|
+
Accepted forms:
|
|
141
191
|
|
|
142
|
-
|
|
143
|
-
(
|
|
192
|
+
- `command("...")` (String): inserted into `ExecStart=` as-is (after stripping surrounding whitespace).
|
|
193
|
+
- `command(["bin", "arg1", "arg2"])` (argv Array): formatted/escaped into a systemd-compatible `ExecStart=` string.
|
|
194
|
+
|
|
195
|
+
If you need shell features (pipes, redirects, globbing, env var expansion), either wrap it yourself, or use `shell`:
|
|
144
196
|
|
|
145
197
|
```ruby
|
|
146
198
|
command "/bin/bash -lc 'echo hello | sed -e s/hello/hi/'"
|
|
199
|
+
command ["/bin/bash", "-lc", "echo hello | sed -e s/hello/hi/"]
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### `shell`
|
|
203
|
+
|
|
204
|
+
`shell("...")` is a convenience helper for the common `/bin/bash -lc` pattern:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
shell "echo hello | sed -e s/hello/hi/"
|
|
147
208
|
```
|
|
148
209
|
|
|
149
210
|
### `every` periods
|
|
@@ -203,11 +264,15 @@ Notes:
|
|
|
203
264
|
- Unit basenames include a stable ID derived from the job’s trigger + command (reordering schedule blocks won’t rename units).
|
|
204
265
|
- `wheneverd write` / `wheneverd reload` prune previously generated units for the identifier by default (use `--no-prune` to keep old units around).
|
|
205
266
|
- `--unit-dir` controls where unit files are written/read/deleted; `activate`/`deactivate` use systemd’s unit search path.
|
|
267
|
+
- `wheneverd diff` returns exit status `0` when no differences are found, and `1` when differences are found.
|
|
206
268
|
|
|
207
269
|
Commands:
|
|
208
270
|
|
|
209
271
|
- `wheneverd init [--schedule PATH] [--force]` writes a template schedule file.
|
|
210
272
|
- `wheneverd show [--schedule PATH] [--identifier NAME]` prints rendered units to stdout.
|
|
273
|
+
- `wheneverd status [--identifier NAME] [--unit-dir PATH]` prints `systemctl --user list-timers` and `systemctl --user status` for installed timers.
|
|
274
|
+
- `wheneverd diff [--schedule PATH] [--identifier NAME] [--unit-dir PATH]` diffs rendered units vs unit files on disk.
|
|
275
|
+
- `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
276
|
- `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
277
|
- `wheneverd delete [--identifier NAME] [--unit-dir PATH] [--dry-run]` deletes previously generated units for the identifier.
|
|
213
278
|
- `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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
3
|
+
require_relative "period_strategy"
|
|
4
4
|
|
|
5
5
|
module Wheneverd
|
|
6
6
|
module DSL
|
|
@@ -8,24 +8,12 @@ module Wheneverd
|
|
|
8
8
|
#
|
|
9
9
|
# Supported period forms are described in the README.
|
|
10
10
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# -
|
|
14
|
-
#
|
|
15
|
-
# -
|
|
16
|
-
# ({Wheneverd::Trigger::Calendar}).
|
|
17
|
-
# - The `at:` option is only valid for calendar triggers, with a convenience exception for
|
|
18
|
-
# `every 1.day, at: ...` which is treated as a daily calendar trigger.
|
|
11
|
+
# Uses a strategy pattern to delegate parsing to specialized strategy classes:
|
|
12
|
+
# - {PeriodStrategy::DurationStrategy} for Duration values
|
|
13
|
+
# - {PeriodStrategy::StringStrategy} for interval strings and cron expressions
|
|
14
|
+
# - {PeriodStrategy::SymbolStrategy} for calendar symbols
|
|
15
|
+
# - {PeriodStrategy::ArrayStrategy} for arrays of calendar symbols
|
|
19
16
|
class PeriodParser
|
|
20
|
-
DAY_SECONDS = 60 * 60 * 24
|
|
21
|
-
REBOOT_NOT_SUPPORTED_MESSAGE =
|
|
22
|
-
"The :reboot period is not supported; use an interval or calendar period instead"
|
|
23
|
-
|
|
24
|
-
CALENDAR_SYMBOLS = %i[
|
|
25
|
-
hour day month year weekday weekend
|
|
26
|
-
monday tuesday wednesday thursday friday saturday sunday
|
|
27
|
-
].freeze
|
|
28
|
-
|
|
29
17
|
attr_reader :path
|
|
30
18
|
|
|
31
19
|
# @param path [String] schedule path for error reporting
|
|
@@ -38,98 +26,11 @@ module Wheneverd
|
|
|
38
26
|
# @return [Wheneverd::Trigger::Interval, Wheneverd::Trigger::Calendar]
|
|
39
27
|
def trigger_for(period, at:)
|
|
40
28
|
at_times = AtNormalizer.normalize(at, path: path)
|
|
41
|
-
|
|
29
|
+
strategy = PeriodStrategy.for(period, path: path)
|
|
30
|
+
strategy.parse(period, at_times: at_times)
|
|
42
31
|
rescue Wheneverd::InvalidIntervalError => e
|
|
43
32
|
raise InvalidPeriodError.new(e.message, path: path)
|
|
44
33
|
end
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
def trigger_for_period(period, at_times:)
|
|
49
|
-
return duration_trigger_for(period, at_times: at_times) if period.is_a?(Wheneverd::Duration)
|
|
50
|
-
return array_trigger_for(period, at_times: at_times) if period.is_a?(Array)
|
|
51
|
-
return string_trigger_for(period, at_times: at_times) if period.is_a?(String)
|
|
52
|
-
return symbol_trigger_for(period, at_times: at_times) if period.is_a?(Symbol)
|
|
53
|
-
|
|
54
|
-
raise InvalidPeriodError.new("Unsupported period type: #{period.class}", path: path)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def duration_trigger_for(duration, at_times:)
|
|
58
|
-
if at_times.any?
|
|
59
|
-
return daily_calendar_trigger(at_times) if duration.to_i == DAY_SECONDS
|
|
60
|
-
|
|
61
|
-
raise InvalidPeriodError.new("at: is only supported with calendar periods", path: path)
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
Wheneverd::Trigger::Interval.new(seconds: duration.to_i)
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def daily_calendar_trigger(at_times)
|
|
68
|
-
Wheneverd::Trigger::Calendar.new(on_calendar: build_calendar_specs("day", at_times))
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def string_trigger_for(str, at_times:)
|
|
72
|
-
period_str = str.strip
|
|
73
|
-
|
|
74
|
-
return interval_trigger(period_str, at_times: at_times) if interval_string?(period_str)
|
|
75
|
-
|
|
76
|
-
return cron_trigger(period_str, at_times: at_times) if cron_string?(period_str)
|
|
77
|
-
|
|
78
|
-
raise InvalidPeriodError.new("Unrecognized period #{period_str.inspect}", path: path)
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def interval_trigger(period_str, at_times:)
|
|
82
|
-
if at_times.any?
|
|
83
|
-
raise InvalidPeriodError.new("at: is not supported for interval periods", path: path)
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
seconds = Wheneverd::Interval.parse(period_str)
|
|
87
|
-
Wheneverd::Trigger::Interval.new(seconds: seconds)
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def cron_trigger(period_str, at_times:)
|
|
91
|
-
if at_times.any?
|
|
92
|
-
raise InvalidPeriodError.new("at: is not supported for cron periods", path: path)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
Wheneverd::Trigger::Calendar.new(on_calendar: ["cron:#{period_str}"])
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def symbol_trigger_for(sym, at_times:)
|
|
99
|
-
raise InvalidPeriodError.new(REBOOT_NOT_SUPPORTED_MESSAGE, path: path) if sym == :reboot
|
|
100
|
-
|
|
101
|
-
if CALENDAR_SYMBOLS.include?(sym)
|
|
102
|
-
return Wheneverd::Trigger::Calendar.new(
|
|
103
|
-
on_calendar: build_calendar_specs(sym.to_s, at_times)
|
|
104
|
-
)
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
raise InvalidPeriodError.new("Unknown period symbol: #{sym.inspect}", path: path)
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def array_trigger_for(periods, at_times:)
|
|
111
|
-
bases = CalendarSymbolPeriodList.validate(
|
|
112
|
-
periods,
|
|
113
|
-
allowed_symbols: CALENDAR_SYMBOLS,
|
|
114
|
-
path: path
|
|
115
|
-
).map(&:to_s)
|
|
116
|
-
specs = bases.flat_map { |base| build_calendar_specs(base, at_times) }.uniq
|
|
117
|
-
Wheneverd::Trigger::Calendar.new(on_calendar: specs)
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def build_calendar_specs(base, at_times)
|
|
121
|
-
return [base] if at_times.empty?
|
|
122
|
-
|
|
123
|
-
at_times.map { |t| "#{base}@#{t}" }
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def interval_string?(str)
|
|
127
|
-
/\A-?\d+[smhdw]\z/.match?(str)
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def cron_string?(str)
|
|
131
|
-
str.split(/\s+/).length == 5
|
|
132
|
-
end
|
|
133
34
|
end
|
|
134
35
|
end
|
|
135
36
|
end
|