wheneverd 0.4.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff946dbc0ba39d3e90ecf3cbed4c9ea81c1818fcd1d206f00cf6763dfe05ebe1
4
- data.tar.gz: cd42856421c97365ff63547252ea4e8ba8a5f17dc822eea8af67b45ce8861b8f
3
+ metadata.gz: 9469e813aa6390a8e7eb911ce672a9fdaf7ab861e435d71d2d8887d551f8b8f8
4
+ data.tar.gz: dcf0211fe96fe0faff489a82ab3d0f12f36c3703083a949606eed4ab8eb9fa28
5
5
  SHA512:
6
- metadata.gz: a4d31fc560c96e1b10b7b42cb0c32e4aa222823e64bde6a17cacb891246d67caefb9e42b5f71ce23a26b14392c33eb819f42fc3e199a0b2f10a0f040bb5e5193
7
- data.tar.gz: fc2e7a43ed41af1b7b00cb4cc8a228054071cec67ca53bb81829134ab884f8a6b5730796a699bdd6066262d3fee18ff0234f535d2fe967b5d26543d2d7b2097e
6
+ metadata.gz: 4a448263264d7322c6622d111f5bf2f51f916ee0dc22193388e4b8b15a4336c3d136c475102db7c772b5be35fd53fa3800fcb733ef3850d620e982baeb9fa9a9
7
+ data.tar.gz: 62aad81a000a60510e6b230660321ec40951ecdcaed1317517f33fa10688fa319aa2afc43f8b003704b364d0f0f68a1d12e95482dce408208269a400ebee9a87
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 4.0.3
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.5.0
9
+
10
+ - Adds top-level `service` DSL entries for long-running systemd user services.
11
+ - `activate`, `deactivate`, `reload`, and `status` now manage standalone services alongside timers.
12
+ - Standalone services render as `Type=simple` units with configurable `Restart=`, `RestartSec=`, and extra `[Service]` settings.
13
+
8
14
  ## 0.4.0
9
15
 
10
16
  - Docs: adds a copy/paste "deploy a simple schedule" example and refines README status section.
data/Gemfile.lock CHANGED
@@ -1,61 +1,64 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- wheneverd (0.3.0)
4
+ wheneverd (0.5.0)
5
5
  clamp (~> 1.3)
6
6
 
7
7
  GEM
8
8
  remote: https://rubygems.org/
9
9
  specs:
10
10
  ast (2.4.3)
11
- clamp (1.3.2)
11
+ clamp (1.5.2)
12
12
  date (3.5.1)
13
13
  docile (1.4.1)
14
- erb (6.0.1)
14
+ drb (2.2.3)
15
+ erb (6.0.4)
15
16
  io-console (0.8.2)
16
- irb (1.16.0)
17
+ irb (1.18.0)
17
18
  pp (>= 0.6.0)
19
+ prism (>= 1.3.0)
18
20
  rdoc (>= 4.0.0)
19
21
  reline (>= 0.4.2)
20
- json (2.18.0)
22
+ json (2.19.4)
21
23
  language_server-protocol (3.17.0.5)
22
24
  lint_roller (1.1.0)
23
- minitest (6.0.1)
25
+ minitest (6.0.5)
26
+ drb (~> 2.0)
24
27
  prism (~> 1.5)
25
- parallel (1.27.0)
26
- parser (3.3.10.0)
28
+ parallel (2.1.0)
29
+ parser (3.3.11.1)
27
30
  ast (~> 2.4.1)
28
31
  racc
29
32
  pp (0.6.3)
30
33
  prettyprint
31
34
  prettyprint (0.2.0)
32
- prism (1.7.0)
35
+ prism (1.9.0)
33
36
  psych (5.3.1)
34
37
  date
35
38
  stringio
36
39
  racc (1.8.1)
37
40
  rainbow (3.1.1)
38
- rake (13.3.1)
39
- rdoc (7.1.0)
41
+ rake (13.4.2)
42
+ rdoc (7.2.0)
40
43
  erb
41
44
  psych (>= 4.0.0)
42
45
  tsort
43
46
  redcarpet (3.6.1)
44
- regexp_parser (2.11.3)
47
+ regexp_parser (2.12.0)
45
48
  reline (0.6.3)
46
49
  io-console (~> 0.5)
47
- rubocop (1.82.1)
50
+ rubocop (1.86.1)
48
51
  json (~> 2.3)
49
52
  language_server-protocol (~> 3.17.0.2)
50
53
  lint_roller (~> 1.1.0)
51
- parallel (~> 1.10)
54
+ parallel (>= 1.10)
52
55
  parser (>= 3.3.0.2)
53
56
  rainbow (>= 2.2.2, < 4.0)
54
57
  regexp_parser (>= 2.9.3, < 3.0)
55
- rubocop-ast (>= 1.48.0, < 2.0)
58
+ rubocop-ast (>= 1.49.0, < 2.0)
56
59
  ruby-progressbar (~> 1.7)
57
60
  unicode-display_width (>= 2.4.0, < 4.0)
58
- rubocop-ast (1.49.0)
61
+ rubocop-ast (1.49.1)
59
62
  parser (>= 3.3.7.2)
60
63
  prism (~> 1.7)
61
64
  ruby-progressbar (1.13.0)
@@ -70,7 +73,7 @@ GEM
70
73
  unicode-display_width (3.2.0)
71
74
  unicode-emoji (~> 4.1)
72
75
  unicode-emoji (4.2.0)
73
- yard (0.9.38)
76
+ yard (0.9.43)
74
77
 
75
78
  PLATFORMS
76
79
  ruby
@@ -89,31 +92,33 @@ DEPENDENCIES
89
92
 
90
93
  CHECKSUMS
91
94
  ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
92
- clamp (1.3.2) sha256=4f6a99a8678d51abbf1650263a74d1ac50939edc11986271431d2e03a0d7a022
95
+ bundler (4.0.11) sha256=5bcec0fb78302e48d02ee46f10ee6e6942be647ba5b44a6d1ddfda9a240ce785
96
+ clamp (1.5.2) sha256=2fed212e5c9b60447eb5097af39d4c12c3c7e6788e8d791e9c436e0e755f4adc
93
97
  date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
94
98
  docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
95
- erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5
99
+ drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
100
+ erb (6.0.4) sha256=38e3803694be357fe2bfe312487c74beaf9fb4e5beb3e22498952fe1645b95d9
96
101
  io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
97
- irb (1.16.0) sha256=2abe56c9ac947cdcb2f150572904ba798c1e93c890c256f8429981a7675b0806
98
- json (2.18.0) sha256=b10506aee4183f5cf49e0efc48073d7b75843ce3782c68dbeb763351c08fd505
102
+ irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
103
+ json (2.19.4) sha256=670a7d333fb3b18ca5b29cb255eb7bef099e40d88c02c80bd42a3f30fe5239ac
99
104
  language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
100
105
  lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
101
- minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb
102
- parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130
103
- parser (3.3.10.0) sha256=ce3587fa5cc55a88c4ba5b2b37621b3329aadf5728f9eafa36bbd121462aabd6
106
+ minitest (6.0.5) sha256=f007d7246bf4feea549502842cd7c6aba8851cdc9c90ba06de9c476c0d01155c
107
+ parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356
108
+ parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54
104
109
  pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6
105
110
  prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
106
- prism (1.7.0) sha256=10062f734bf7985c8424c44fac382ac04a58124ea3d220ec3ba9fe4f2da65103
111
+ prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
107
112
  psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974
108
113
  racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
109
114
  rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
110
- rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
111
- rdoc (7.1.0) sha256=494899df0706c178596ca6e1d50f1b7eb285a9b2aae715be5abd742734f17363
115
+ rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
116
+ rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192
112
117
  redcarpet (3.6.1) sha256=d444910e6aa55480c6bcdc0cdb057626e8a32c054c29e793fa642ba2f155f445
113
- regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4
118
+ regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
114
119
  reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
115
- rubocop (1.82.1) sha256=09f1a6a654a960eda767aebea33e47603080f8e9c9a3f019bf9b94c9cab5e273
116
- rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd
120
+ rubocop (1.86.1) sha256=44415f3f01d01a21e01132248d2fd0867572475b566ca188a0a42133a08d4531
121
+ rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035
117
122
  ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
118
123
  simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5
119
124
  simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246
@@ -122,8 +127,8 @@ CHECKSUMS
122
127
  tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
123
128
  unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
124
129
  unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
125
- wheneverd (0.3.0)
126
- yard (0.9.38) sha256=721fb82afb10532aa49860655f6cc2eaa7130889df291b052e1e6b268283010f
130
+ wheneverd (0.5.0)
131
+ yard (0.9.43) sha256=cf8733a8f0485df2a162927e9b5f182215a61f6d22de096b8f402c726a1c5821
127
132
 
128
133
  BUNDLED WITH
129
- 4.0.3
134
+ 4.0.11
data/README.md CHANGED
@@ -167,7 +167,7 @@ Schedules are defined in a Ruby file (default: `config/schedule.rb`) and evaluat
167
167
 
168
168
  Note: schedule files are executed as Ruby. Do not run untrusted schedule code.
169
169
 
170
- The core shape is:
170
+ The core timer shape is:
171
171
 
172
172
  ```ruby
173
173
  every(period, at: nil) do
@@ -175,6 +175,20 @@ every(period, at: nil) do
175
175
  end
176
176
  ```
177
177
 
178
+ Long-running services can be managed from the same schedule with top-level
179
+ `service` entries:
180
+
181
+ ```ruby
182
+ service "worker",
183
+ shell: "bundle exec bin/worker",
184
+ restart: "always",
185
+ restart_sec: "5s",
186
+ service: {
187
+ "WorkingDirectory" => "/srv/apps/myapp/current",
188
+ "Environment" => "RAILS_ENV=production"
189
+ }
190
+ ```
191
+
178
192
  For calendar schedules, you can also pass multiple period symbols (or an array) to run the same jobs on multiple days:
179
193
 
180
194
  ```ruby
@@ -270,14 +284,14 @@ Commands:
270
284
 
271
285
  - `wheneverd init [--schedule PATH] [--force]` writes a template schedule file.
272
286
  - `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.
287
+ - `wheneverd status [--identifier NAME] [--unit-dir PATH]` prints `systemctl --user list-timers` and `systemctl --user status` for installed timers/services.
274
288
  - `wheneverd diff [--schedule PATH] [--identifier NAME] [--unit-dir PATH]` diffs rendered units vs unit files on disk.
275
289
  - `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).
276
290
  - `wheneverd write [--schedule PATH] [--identifier NAME] [--unit-dir PATH] [--dry-run] [--[no-]prune]` writes units to disk (or prints paths in `--dry-run` mode).
277
291
  - `wheneverd delete [--identifier NAME] [--unit-dir PATH] [--dry-run]` deletes previously generated units for the identifier.
278
- - `wheneverd activate [--schedule PATH] [--identifier NAME]` runs `systemctl --user daemon-reload` and enables/starts the timers.
279
- - `wheneverd deactivate [--schedule PATH] [--identifier NAME]` stops and disables the timers.
280
- - `wheneverd reload [--schedule PATH] [--identifier NAME] [--unit-dir PATH] [--[no-]prune]` writes units, reloads systemd, and restarts timers.
292
+ - `wheneverd activate [--schedule PATH] [--identifier NAME]` runs `systemctl --user daemon-reload` and enables/starts the timers/services.
293
+ - `wheneverd deactivate [--schedule PATH] [--identifier NAME]` stops and disables the timers/services.
294
+ - `wheneverd reload [--schedule PATH] [--identifier NAME] [--unit-dir PATH] [--[no-]prune]` writes units, reloads systemd, and restarts timers/services.
281
295
  - `wheneverd current [--identifier NAME] [--unit-dir PATH]` prints the currently installed unit file contents from disk.
282
296
  - `wheneverd linger [--user NAME] [enable|disable|status]` manages lingering via `loginctl` (`status` is the default).
283
297
 
@@ -1,16 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wheneverd
4
- # Implements `wheneverd activate` (enable + start timers via `systemctl --user`).
4
+ # Implements `wheneverd activate` (enable + start timers/services via `systemctl --user`).
5
5
  class CLI::Activate < CLI
6
6
  def execute
7
- timer_units = timer_unit_basenames
8
- return 0 if timer_units.empty?
7
+ units = activatable_unit_basenames
8
+ return 0 if units.empty?
9
9
 
10
10
  Wheneverd::Systemd::Systemctl.run("daemon-reload")
11
- Wheneverd::Systemd::Systemctl.run("enable", "--now", *timer_units)
11
+ Wheneverd::Systemd::Systemctl.run("enable", "--now", *units)
12
12
 
13
- timer_units.each { |unit| puts unit }
13
+ units.each { |unit| puts unit }
14
14
  0
15
15
  rescue StandardError => e
16
16
  handle_error(e)
@@ -1,16 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wheneverd
4
- # Implements `wheneverd deactivate` (stop + disable timers via `systemctl --user`).
4
+ # Implements `wheneverd deactivate` (stop + disable timers/services via `systemctl --user`).
5
5
  class CLI::Deactivate < CLI
6
6
  def execute
7
- timer_units = timer_unit_basenames
8
- return 0 if timer_units.empty?
7
+ units = activatable_unit_basenames
8
+ return 0 if units.empty?
9
9
 
10
- Wheneverd::Systemd::Systemctl.run("stop", *timer_units)
11
- Wheneverd::Systemd::Systemctl.run("disable", *timer_units)
10
+ Wheneverd::Systemd::Systemctl.run("stop", *units)
11
+ Wheneverd::Systemd::Systemctl.run("disable", *units)
12
12
 
13
- timer_units.each { |unit| puts unit }
13
+ units.each { |unit| puts unit }
14
14
  0
15
15
  rescue StandardError => e
16
16
  handle_error(e)
@@ -1,17 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wheneverd
4
- # Implements `wheneverd reload` (write units, reload daemon, restart timers).
4
+ # Implements `wheneverd reload` (write units, reload daemon, restart timers/services).
5
5
  class CLI::Reload < CLI
6
6
  option "--[no-]prune", :flag,
7
7
  "Prune previously generated units for the identifier (default: enabled)",
8
8
  default: true
9
9
 
10
10
  def execute
11
- paths, timer_units = write_units_and_timer_basenames
12
- return 0 if timer_units.empty?
11
+ paths, units = write_units_and_activatable_basenames
12
+ return 0 if units.empty?
13
13
 
14
- reload_systemd(timer_units)
14
+ reload_systemd(units)
15
15
 
16
16
  paths.each { |path| puts path }
17
17
  0
@@ -21,7 +21,7 @@ module Wheneverd
21
21
 
22
22
  private
23
23
 
24
- def write_units_and_timer_basenames
24
+ def write_units_and_activatable_basenames
25
25
  units = render_units
26
26
  paths = Wheneverd::Systemd::UnitWriter.write(
27
27
  units,
@@ -29,12 +29,12 @@ module Wheneverd
29
29
  prune: prune?,
30
30
  identifier: identifier_value
31
31
  )
32
- [paths, timer_unit_basenames(units)]
32
+ [paths, activatable_unit_basenames(units)]
33
33
  end
34
34
 
35
- def reload_systemd(timer_units)
35
+ def reload_systemd(units)
36
36
  Wheneverd::Systemd::Systemctl.run("daemon-reload")
37
- Wheneverd::Systemd::Systemctl.run("restart", *timer_units)
37
+ Wheneverd::Systemd::Systemctl.run("restart", *units)
38
38
  end
39
39
  end
40
40
  end
@@ -1,14 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wheneverd
4
- # Implements `wheneverd status` (show installed timer status via `systemctl --user`).
4
+ # Implements `wheneverd status` (show installed timer/service status via `systemctl --user`).
5
5
  class CLI::Status < CLI
6
6
  def execute
7
- timer_units = installed_timer_unit_basenames
8
- return 0 if timer_units.empty?
7
+ timer_units, service_units = installed_unit_basenames
8
+ units = timer_units + service_units
9
+ return 0 if units.empty?
9
10
 
10
11
  print_list_timers(timer_units)
11
- print_status(timer_units)
12
+ print_status(units)
12
13
  0
13
14
  rescue StandardError => e
14
15
  handle_error(e)
@@ -17,12 +18,18 @@ module Wheneverd
17
18
  private
18
19
 
19
20
  # @return [Array<String>]
20
- def installed_timer_unit_basenames
21
+ def installed_unit_basenames
21
22
  paths = Wheneverd::Systemd::UnitLister.list(identifier: identifier_value, unit_dir: unit_dir)
22
- paths.map { |path| File.basename(path) }.grep(/\.timer\z/).uniq
23
+ basenames = paths.map { |path| File.basename(path) }
24
+ timers = basenames.grep(/\.timer\z/).uniq
25
+ timer_managed_services = timers.map { |timer| timer.sub(/\.timer\z/, ".service") }
26
+ services = basenames.grep(/\.service\z/).uniq - timer_managed_services
27
+ [timers, services]
23
28
  end
24
29
 
25
30
  def print_list_timers(timer_units)
31
+ return if timer_units.empty?
32
+
26
33
  stdout, = Wheneverd::Systemd::Systemctl.run("list-timers", "--all", *timer_units)
27
34
  print stdout
28
35
  end
data/lib/wheneverd/cli.rb CHANGED
@@ -51,12 +51,14 @@ module Wheneverd
51
51
  end
52
52
 
53
53
  # @param units [Array<Wheneverd::Systemd::Unit>]
54
- # @return [Array<String>] timer unit basenames
55
- def timer_unit_basenames(units = render_units)
56
- units.select { |unit| unit.kind == :timer }.map(&:path_basename).uniq
54
+ # @return [Array<String>] timer and standalone service unit basenames
55
+ def activatable_unit_basenames(units = render_units)
56
+ units.select { |unit| %i[timer service].include?(unit.activation) }
57
+ .map(&:path_basename)
58
+ .uniq
57
59
  end
58
60
 
59
- private :render_units, :timer_unit_basenames
61
+ private :render_units, :activatable_unit_basenames
60
62
  end
61
63
  end
62
64
 
@@ -81,14 +83,14 @@ module Wheneverd
81
83
  subcommand "help", "Show help", Wheneverd::CLI::Help
82
84
  subcommand "init", "Create a schedule template", Wheneverd::CLI::Init
83
85
  subcommand "show", "Render units to stdout", Wheneverd::CLI::Show
84
- subcommand "status", "Show systemctl list-timers + status for this identifier", Wheneverd::CLI::Status
86
+ subcommand "status", "Show systemctl status for this identifier", Wheneverd::CLI::Status
85
87
  subcommand "diff", "Diff rendered units vs files on disk", Wheneverd::CLI::Diff
86
88
  subcommand "validate", "Validate schedule via systemd-analyze", Wheneverd::CLI::Validate
87
89
  subcommand "write", "Write units to disk", Wheneverd::CLI::Write
88
90
  subcommand "delete", "Delete units from disk", Wheneverd::CLI::Delete
89
- subcommand "activate", "Enable and start timers via systemctl --user", Wheneverd::CLI::Activate
90
- subcommand "deactivate", "Stop and disable timers via systemctl --user", Wheneverd::CLI::Deactivate
91
- subcommand "reload", "Write units, reload daemon, restart timers", Wheneverd::CLI::Reload
91
+ subcommand "activate", "Enable and start timers/services via systemctl --user", Wheneverd::CLI::Activate
92
+ subcommand "deactivate", "Stop and disable timers/services via systemctl --user", Wheneverd::CLI::Deactivate
93
+ subcommand "reload", "Write units, reload daemon, restart timers/services", Wheneverd::CLI::Reload
92
94
  subcommand "current", "Show installed units from disk", Wheneverd::CLI::Current
93
95
  subcommand "linger", "Manage systemd user lingering via loginctl", Wheneverd::CLI::Linger
94
96
  end
@@ -75,6 +75,37 @@ module Wheneverd
75
75
  command([shell_executable, "-lc", script_stripped])
76
76
  end
77
77
 
78
+ # Add a long-running systemd user service to the schedule.
79
+ #
80
+ # @example Shell service
81
+ # service "worker", shell: "bundle exec bin/worker"
82
+ #
83
+ # @example Argv service
84
+ # service "worker", command: ["bundle", "exec", "bin/worker"]
85
+ #
86
+ # @param name [String] stable service name within the schedule
87
+ # @param command [String, Array<String>, nil]
88
+ # @param shell [String, nil] shell script to run via /bin/bash -lc
89
+ # @param restart [String]
90
+ # @param restart_sec [String]
91
+ # @param service [Hash, Array<String>] extra [Service] lines
92
+ # @return [Wheneverd::Service]
93
+ def service(name, command: nil, shell: nil, restart: "always", restart_sec: "5s",
94
+ service: {})
95
+ command_value = normalize_service_command(command: command, shell: shell)
96
+ service_obj = Wheneverd::Service.new(
97
+ name: name,
98
+ command: command_value,
99
+ restart: restart,
100
+ restart_sec: restart_sec,
101
+ service: service
102
+ )
103
+ schedule.add_service(service_obj)
104
+ service_obj
105
+ rescue Wheneverd::InvalidCommandError => e
106
+ raise LoadError.new(e.message, path: path)
107
+ end
108
+
78
109
  private
79
110
 
80
111
  def ensure_in_every_block!(name)
@@ -102,6 +133,21 @@ module Wheneverd
102
133
  stripped
103
134
  end
104
135
 
136
+ def normalize_service_command(command:, shell:)
137
+ if command && shell
138
+ raise LoadError.new("service() accepts command: or shell:, not both", path: path)
139
+ end
140
+
141
+ return command if command
142
+
143
+ if shell
144
+ script = normalize_shell_script(shell)
145
+ return ["/bin/bash", "-lc", script]
146
+ end
147
+
148
+ raise LoadError.new("service() requires command: or shell:", path: path)
149
+ end
150
+
105
151
  def with_current_entry(entry)
106
152
  previous_entry = @current_entry
107
153
  @current_entry = entry
@@ -8,9 +8,14 @@ module Wheneverd
8
8
  # @return [Array<Entry>]
9
9
  attr_reader :entries
10
10
 
11
+ # @return [Array<Wheneverd::Service>]
12
+ attr_reader :services
13
+
11
14
  # @param entries [Array<Entry>]
12
- def initialize(entries: [])
15
+ # @param services [Array<Wheneverd::Service>]
16
+ def initialize(entries: [], services: [])
13
17
  @entries = entries.dup
18
+ @services = services.dup
14
19
  end
15
20
 
16
21
  # Append an entry to the schedule.
@@ -21,5 +26,14 @@ module Wheneverd
21
26
  entries << entry
22
27
  self
23
28
  end
29
+
30
+ # Append a long-running service to the schedule.
31
+ #
32
+ # @param service [Wheneverd::Service]
33
+ # @return [Schedule] self
34
+ def add_service(service)
35
+ services << service
36
+ self
37
+ end
24
38
  end
25
39
  end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ # A long-running systemd user service managed alongside scheduled timers.
5
+ class Service
6
+ SAFE_SETTING_NAME = /\A[A-Za-z][A-Za-z0-9]*\z/.freeze
7
+
8
+ # @return [String]
9
+ attr_reader :name
10
+
11
+ # @return [Wheneverd::Job::Command]
12
+ attr_reader :command
13
+
14
+ # @return [String]
15
+ attr_reader :restart
16
+
17
+ # @return [String]
18
+ attr_reader :restart_sec
19
+
20
+ # @return [Array<String>]
21
+ attr_reader :service_lines
22
+
23
+ # @param name [String] stable service name within the schedule
24
+ # @param command [String, Array<String>] command to run as ExecStart
25
+ # @param restart [String] systemd Restart= value
26
+ # @param restart_sec [String] systemd RestartSec= value
27
+ # @param service [Hash, Array<String>] extra [Service] lines
28
+ def initialize(name:, command:, restart: "always", restart_sec: "5s", service: {})
29
+ @name = normalize_name(name)
30
+ @command = Wheneverd::Job::Command.new(command: command)
31
+ @restart = normalize_required_value(restart, "restart")
32
+ @restart_sec = normalize_required_value(restart_sec, "restart_sec")
33
+ @service_lines = normalize_service_lines(service)
34
+ end
35
+
36
+ # Stable signature used for unit naming.
37
+ #
38
+ # @return [String]
39
+ def signature
40
+ [
41
+ "service:#{name}",
42
+ command.signature,
43
+ "restart:#{restart}",
44
+ "restart_sec:#{restart_sec}",
45
+ *service_lines
46
+ ].join("\n")
47
+ end
48
+
49
+ private
50
+
51
+ def normalize_name(value)
52
+ str = value.to_s.strip
53
+ raise InvalidCommandError, "Service name must not be empty" if str.empty?
54
+
55
+ str
56
+ end
57
+
58
+ def normalize_required_value(value, field)
59
+ str = value.to_s.strip
60
+ raise InvalidCommandError, "Service #{field} must not be empty" if str.empty?
61
+
62
+ str
63
+ end
64
+
65
+ def normalize_service_lines(value)
66
+ case value
67
+ when Hash
68
+ value.map { |key, setting| normalize_service_setting(key, setting) }
69
+ when Array
70
+ value.map { |line| normalize_service_line(line) }
71
+ else
72
+ raise InvalidCommandError, "Service extra settings must be a Hash or Array"
73
+ end
74
+ end
75
+
76
+ def normalize_service_setting(key, value)
77
+ setting_name = key.to_s.strip
78
+ unless SAFE_SETTING_NAME.match?(setting_name)
79
+ raise InvalidCommandError, "Invalid service setting name: #{setting_name.inspect}"
80
+ end
81
+
82
+ "#{setting_name}=#{normalize_service_setting_value(value)}"
83
+ end
84
+
85
+ def normalize_service_setting_value(value)
86
+ str = value.to_s.strip
87
+ raise InvalidCommandError, "Service setting values must not be empty" if str.empty?
88
+ if str.match?(/[\0\r\n]/)
89
+ raise InvalidCommandError, "Service setting values must not include NUL or newlines"
90
+ end
91
+
92
+ str
93
+ end
94
+
95
+ def normalize_service_line(line)
96
+ str = line.to_s.strip
97
+ raise InvalidCommandError, "Service lines must not be empty" if str.empty?
98
+ if str.match?(/[\0\r\n]/) || !str.include?("=")
99
+ raise InvalidCommandError, "Service lines must be single KEY=VALUE lines"
100
+ end
101
+
102
+ str
103
+ end
104
+ end
105
+ end
@@ -10,7 +10,7 @@ module Wheneverd
10
10
  # @return [Symbol] `:service` or `:timer`
11
11
  # @!attribute [r] contents
12
12
  # @return [String] unit file contents
13
- Unit = Struct.new(:path_basename, :kind, :contents, keyword_init: true)
13
+ Unit = Struct.new(:path_basename, :kind, :contents, :activation, keyword_init: true)
14
14
 
15
15
  # Renders a {Wheneverd::Schedule} into systemd units.
16
16
  #
@@ -52,7 +52,7 @@ module Wheneverd
52
52
  stable_ids = Wheneverd::Systemd::UnitNamer.stable_ids_for(schedule)
53
53
  stable_id_index = 0
54
54
 
55
- schedule.entries.flat_map do |entry|
55
+ units = schedule.entries.flat_map do |entry|
56
56
  entry.jobs.flat_map do |job|
57
57
  stable_id = stable_ids.fetch(stable_id_index)
58
58
  stable_id_index += 1
@@ -60,9 +60,20 @@ module Wheneverd
60
60
  render_job(base, entry.trigger, job)
61
61
  end
62
62
  end
63
+ units.concat(render_services(schedule.services, id))
64
+ units
63
65
  end
64
66
  private_class_method :render_schedule
65
67
 
68
+ def self.render_services(services, id)
69
+ services.map do |service|
70
+ stable_id = Wheneverd::Systemd::UnitNamer.stable_id_for(service.signature)
71
+ base = "wheneverd-#{id}-#{stable_id}"
72
+ build_standalone_service_unit("#{base}.service", service)
73
+ end
74
+ end
75
+ private_class_method :render_services
76
+
66
77
  def self.render_job(base, trigger, job)
67
78
  service = build_service_unit("#{base}.service", job)
68
79
  timer = build_timer_unit("#{base}.timer", trigger)
@@ -74,17 +85,29 @@ module Wheneverd
74
85
  Unit.new(
75
86
  path_basename: path_basename,
76
87
  kind: :service,
77
- contents: UnitContentBuilder.service_contents(path_basename, job.command)
88
+ contents: UnitContentBuilder.service_contents(path_basename, job.command),
89
+ activation: :timer_managed
78
90
  )
79
91
  end
80
92
  private_class_method :build_service_unit
81
93
 
94
+ def self.build_standalone_service_unit(path_basename, service)
95
+ Unit.new(
96
+ path_basename: path_basename,
97
+ kind: :service,
98
+ contents: UnitContentBuilder.standalone_service_contents(path_basename, service),
99
+ activation: :service
100
+ )
101
+ end
102
+ private_class_method :build_standalone_service_unit
103
+
82
104
  def self.build_timer_unit(path_basename, trigger)
83
105
  timer_lines = UnitContentBuilder.timer_lines_for(trigger)
84
106
  Unit.new(
85
107
  path_basename: path_basename,
86
108
  kind: :timer,
87
- contents: UnitContentBuilder.timer_contents(path_basename, timer_lines)
109
+ contents: UnitContentBuilder.timer_contents(path_basename, timer_lines),
110
+ activation: :timer
88
111
  )
89
112
  end
90
113
  private_class_method :build_timer_unit
@@ -22,6 +22,29 @@ module Wheneverd
22
22
  )
23
23
  end
24
24
 
25
+ # Build a long-running service unit file.
26
+ #
27
+ # @param path_basename [String] unit file name for description
28
+ # @param service [Wheneverd::Service]
29
+ # @return [String] complete unit file contents
30
+ def self.standalone_service_contents(path_basename, service)
31
+ build_unit(
32
+ description: "wheneverd service #{path_basename}",
33
+ sections: [
34
+ "[Service]",
35
+ "Type=simple",
36
+ "ExecStart=#{service.command.command}",
37
+ "Restart=#{service.restart}",
38
+ "RestartSec=#{service.restart_sec}",
39
+ *service.service_lines,
40
+ "",
41
+ "[Install]",
42
+ "WantedBy=default.target",
43
+ ""
44
+ ]
45
+ )
46
+ end
47
+
25
48
  # Build timer unit file contents.
26
49
  #
27
50
  # @param path_basename [String] unit file name for description
@@ -55,7 +55,6 @@ module Wheneverd
55
55
  def self.stable_id_for(signature)
56
56
  Digest::SHA256.hexdigest(signature).slice(0, 12)
57
57
  end
58
- private_class_method :stable_id_for
59
58
  end
60
59
  end
61
60
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Wheneverd
4
4
  # Gem version.
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.0"
6
6
  end
data/lib/wheneverd.rb CHANGED
@@ -19,6 +19,7 @@ require_relative "wheneverd/duration"
19
19
  require_relative "wheneverd/interval"
20
20
  require_relative "wheneverd/core_ext/numeric_duration"
21
21
  require_relative "wheneverd/job/command"
22
+ require_relative "wheneverd/service"
22
23
  require_relative "wheneverd/trigger/base"
23
24
  require_relative "wheneverd/trigger/interval"
24
25
  require_relative "wheneverd/trigger/calendar"
@@ -37,6 +37,33 @@ class CLIActivateSuccessTest < Minitest::Test
37
37
  includes: expected_timer_basenames)
38
38
  end
39
39
  end
40
+
41
+ def test_runs_enable_now_for_standalone_services
42
+ with_service_project_dir do
43
+ status, out, err, calls = run_activate_with_capture3_stub
44
+ assert_cli_success(status, err)
45
+ service = expected_standalone_service_basenames.fetch(0)
46
+ assert_includes out, service
47
+ assert_systemctl_call_starts_with(calls, 1, SYSTEMCTL_USER_PREFIX + ["enable", "--now"],
48
+ includes: service)
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def with_service_project_dir
55
+ with_project_dir do
56
+ FileUtils.mkdir_p("config")
57
+ File.write("config/schedule.rb", <<~RUBY)
58
+ service "worker", shell: "bin/worker"
59
+ RUBY
60
+ yield
61
+ end
62
+ end
63
+
64
+ def expected_standalone_service_basenames
65
+ expected_units.select { |unit| unit.activation == :service }.map(&:path_basename)
66
+ end
40
67
  end
41
68
 
42
69
  class CLIActivateScheduleMissingTest < Minitest::Test
@@ -41,6 +41,29 @@ class CLIReloadSuccessTest < Minitest::Test
41
41
  includes: expected_timer_basenames)
42
42
  end
43
43
  end
44
+
45
+ def test_restarts_standalone_services
46
+ with_service_project_dir do |project_dir|
47
+ unit_dir = File.join(project_dir, "tmp_units")
48
+ status, _out, err, calls = run_reload_with_capture3_stub(unit_dir: unit_dir)
49
+ assert_cli_success(status, err)
50
+ service = expected_units.find { |unit| unit.activation == :service }.path_basename
51
+ assert_systemctl_call_starts_with(calls, 1, SYSTEMCTL_USER_PREFIX + ["restart"],
52
+ includes: service)
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def with_service_project_dir
59
+ with_project_dir do |project_dir|
60
+ FileUtils.mkdir_p("config")
61
+ File.write("config/schedule.rb", <<~RUBY)
62
+ service "worker", shell: "bin/worker"
63
+ RUBY
64
+ yield project_dir
65
+ end
66
+ end
44
67
  end
45
68
 
46
69
  class CLIReloadScheduleMissingTest < Minitest::Test
@@ -6,11 +6,11 @@ require_relative "support/cli_test_helpers"
6
6
  class CLIStatusTest < Minitest::Test
7
7
  include CLITestHelpers
8
8
 
9
- def test_runs_list_timers_and_status_for_each_installed_timer
9
+ def test_runs_list_timers_and_status_for_each_installed_unit
10
10
  with_installed_units do |unit_dir|
11
11
  status, _out, err, calls = run_status(unit_dir)
12
12
  assert_cli_success(status, err)
13
- assert_status_calls(calls, expected_timer_units)
13
+ assert_status_calls(calls, expected_timer_units, expected_service_units)
14
14
  end
15
15
  end
16
16
 
@@ -39,6 +39,9 @@ class CLIStatusTest < Minitest::Test
39
39
 
40
40
  def with_installed_units
41
41
  with_inited_project_dir do |project_dir|
42
+ File.open(File.join(project_dir, "config", "schedule.rb"), "a") do |file|
43
+ file.puts 'service "worker", shell: "bin/worker"'
44
+ end
42
45
  unit_dir = File.join(project_dir, "tmp_units")
43
46
  assert_equal 0, run_cli(["write", "--identifier", "demo", "--unit-dir", unit_dir]).first
44
47
  yield unit_dir
@@ -53,9 +56,16 @@ class CLIStatusTest < Minitest::Test
53
56
  expected_timer_basenames(identifier: "demo").sort
54
57
  end
55
58
 
56
- def assert_status_calls(calls, expected_timers)
59
+ def expected_service_units
60
+ expected_units(identifier: "demo")
61
+ .select { |unit| unit.activation == :service }
62
+ .map(&:path_basename)
63
+ .sort
64
+ end
65
+
66
+ def assert_status_calls(calls, expected_timers, expected_services)
57
67
  assert_list_timers_call(calls, expected_timers)
58
- assert_status_unit_calls(calls, expected_timers)
68
+ assert_status_unit_calls(calls, (expected_timers + expected_services).sort)
59
69
  end
60
70
 
61
71
  def assert_list_timers_call(calls, expected_timers)
@@ -15,6 +15,37 @@ class DSLContextShellTest < Minitest::Test
15
15
  assert_includes error.message, "shell() script must be a String"
16
16
  end
17
17
 
18
+ def test_service_accepts_shell_command
19
+ ctx = Wheneverd::DSL::Context.new(path: "/tmp/config/schedule.rb")
20
+ ctx.service("live-poller", shell: "bundle exec bin/live")
21
+
22
+ service = ctx.schedule.services.fetch(0)
23
+ assert_equal "live-poller", service.name
24
+ assert_equal ["/bin/bash", "-lc", "bundle exec bin/live"], service.command.argv
25
+ end
26
+
27
+ def test_service_requires_command_or_shell
28
+ ctx = Wheneverd::DSL::Context.new(path: "/tmp/config/schedule.rb")
29
+ error = assert_raises(Wheneverd::DSL::LoadError) { ctx.service("live-poller") }
30
+ assert_includes error.message, "service() requires command: or shell:"
31
+ end
32
+
33
+ def test_service_rejects_command_and_shell_together
34
+ ctx = Wheneverd::DSL::Context.new(path: "/tmp/config/schedule.rb")
35
+ error = assert_raises(Wheneverd::DSL::LoadError) do
36
+ ctx.service("live-poller", command: "bin/live", shell: "bin/live")
37
+ end
38
+ assert_includes error.message, "service() accepts command: or shell:, not both"
39
+ end
40
+
41
+ def test_service_wraps_validation_errors_as_load_errors
42
+ ctx = Wheneverd::DSL::Context.new(path: "/tmp/config/schedule.rb")
43
+ error = assert_raises(Wheneverd::DSL::LoadError) do
44
+ ctx.service("live-poller", command: "bin/live", service: { "Bad-Key" => "1" })
45
+ end
46
+ assert_includes error.message, "Invalid service setting name"
47
+ end
48
+
18
49
  def test_shell_rejects_empty_script
19
50
  ctx = Wheneverd::DSL::Context.new(path: "/tmp/config/schedule.rb")
20
51
  error = assert_raises(Wheneverd::DSL::LoadError) { ctx.every("1m") { shell(" ") } }
@@ -37,11 +37,84 @@ module SystemdRendererTestHelpers
37
37
  )
38
38
  end
39
39
 
40
+ def render_schedule(schedule, identifier: "demo")
41
+ Wheneverd::Systemd::Renderer.render(schedule, identifier: identifier)
42
+ end
43
+
40
44
  def marker
41
45
  "# Generated by wheneverd (wheneverd) #{Wheneverd::VERSION}; do not edit."
42
46
  end
43
47
  end
44
48
 
49
+ class SystemdRendererServiceTest < Minitest::Test
50
+ include SystemdRendererTestHelpers
51
+
52
+ def test_standalone_service_contains_restart_and_extra_service_settings
53
+ service = Wheneverd::Service.new(
54
+ name: "live-poller",
55
+ command: ["/bin/bash", "-lc", "bundle exec bin/live"],
56
+ restart: "always",
57
+ restart_sec: "5s",
58
+ service: { "Nice" => "10", "CPUQuota" => "80%" }
59
+ )
60
+ units = render_schedule(Wheneverd::Schedule.new(services: [service]))
61
+ unit = units.fetch(0)
62
+
63
+ assert_equal :service, unit.kind
64
+ assert_equal :service, unit.activation
65
+ assert_includes unit.contents, marker
66
+ assert_includes unit.contents, "Description=wheneverd service #{unit.path_basename}"
67
+ assert_includes unit.contents, "Type=simple"
68
+ assert_includes unit.contents, 'ExecStart=/bin/bash -lc "bundle exec bin/live"'
69
+ assert_includes unit.contents, "Restart=always"
70
+ assert_includes unit.contents, "RestartSec=5s"
71
+ assert_includes unit.contents, "Nice=10"
72
+ assert_includes unit.contents, "CPUQuota=80%"
73
+ assert_includes unit.contents, "WantedBy=default.target"
74
+ end
75
+
76
+ def test_standalone_service_accepts_array_service_lines
77
+ service = Wheneverd::Service.new(
78
+ name: "live-poller",
79
+ command: "bin/live",
80
+ service: ["Environment=RAILS_ENV=production"]
81
+ )
82
+ unit = render_schedule(Wheneverd::Schedule.new(services: [service])).fetch(0)
83
+
84
+ assert_includes unit.contents, "Environment=RAILS_ENV=production"
85
+ end
86
+
87
+ def test_standalone_service_rejects_invalid_service_lines
88
+ assert_raises(Wheneverd::InvalidCommandError) do
89
+ Wheneverd::Service.new(name: "live-poller", command: "bin/live", service: ["NoEquals"])
90
+ end
91
+ end
92
+
93
+ def test_standalone_service_rejects_invalid_service_settings_collection
94
+ assert_raises(Wheneverd::InvalidCommandError) do
95
+ Wheneverd::Service.new(name: "live-poller", command: "bin/live", service: "Nice=10")
96
+ end
97
+ end
98
+
99
+ def test_standalone_service_rejects_newlines_in_setting_values
100
+ assert_raises(Wheneverd::InvalidCommandError) do
101
+ Wheneverd::Service.new(name: "live-poller", command: "bin/live",
102
+ service: { "Environment" => "A=1\nB=2" })
103
+ end
104
+ end
105
+
106
+ def test_standalone_service_naming_is_stable
107
+ service = Wheneverd::Service.new(name: "worker", command: "bin/worker")
108
+ schedule = Wheneverd::Schedule.new(services: [service])
109
+
110
+ units1 = render_schedule(schedule)
111
+ units2 = render_schedule(schedule)
112
+
113
+ assert_equal units1.map(&:path_basename), units2.map(&:path_basename)
114
+ assert_match(/\Awheneverd-demo-[0-9a-f]{12}\.service\z/, units1.fetch(0).path_basename)
115
+ end
116
+ end
117
+
45
118
  class SystemdRendererIntervalTest < Minitest::Test
46
119
  include SystemdRendererTestHelpers
47
120
 
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.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - bigcurl
@@ -32,6 +32,7 @@ extra_rdoc_files: []
32
32
  files:
33
33
  - ".gitignore"
34
34
  - ".rubocop.yml"
35
+ - ".ruby-version"
35
36
  - ".yardopts"
36
37
  - AGENTS.md
37
38
  - CHANGELOG.md
@@ -78,6 +79,7 @@ files:
78
79
  - lib/wheneverd/interval.rb
79
80
  - lib/wheneverd/job/command.rb
80
81
  - lib/wheneverd/schedule.rb
82
+ - lib/wheneverd/service.rb
81
83
  - lib/wheneverd/systemd/analyze.rb
82
84
  - lib/wheneverd/systemd/calendar_spec.rb
83
85
  - lib/wheneverd/systemd/cron_parser.rb
@@ -154,7 +156,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
154
156
  - !ruby/object:Gem::Version
155
157
  version: '0'
156
158
  requirements: []
157
- rubygems_version: 4.0.4
159
+ rubygems_version: 4.0.11
158
160
  specification_version: 4
159
161
  summary: Wheneverd is to systemd timers what whenever is to cron.
160
162
  test_files: []