ruby_raider 4.1.0 → 4.2.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.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/e2e_tests.yml +3 -0
  3. data/README.md +52 -53
  4. data/RELEASE.md +14 -1
  5. data/lib/commands/schedule_commands.rb +84 -0
  6. data/lib/generators/templates/common/git_ignore.tt +1 -0
  7. data/lib/ruby_raider/runtime/retry_reset.rb +32 -0
  8. data/lib/ruby_raider/runtime/rspec.rb +1 -0
  9. data/lib/ruby_raider/runtime/show_browser.rb +53 -0
  10. data/lib/ruby_raider/runtime.rb +2 -0
  11. data/lib/ruby_raider.rb +22 -7
  12. data/lib/utilities/schedules/adapter.rb +22 -0
  13. data/lib/utilities/schedules/base.rb +27 -0
  14. data/lib/utilities/schedules/kind.rb +74 -0
  15. data/lib/utilities/schedules/legacy.rb +196 -0
  16. data/lib/utilities/schedules/legacy_stub.rb +62 -0
  17. data/lib/utilities/{flows → schedules}/next_run.rb +2 -2
  18. data/lib/utilities/{flows → schedules}/notifier.rb +6 -6
  19. data/lib/utilities/{flows → schedules}/os/command_runner.rb +1 -1
  20. data/lib/utilities/{flows → schedules}/os/cron.rb +27 -17
  21. data/lib/utilities/{flows → schedules}/os/launchd.rb +32 -14
  22. data/lib/utilities/{flows → schedules}/os/linux.rb +23 -5
  23. data/lib/utilities/{flows → schedules}/os/systemd.rb +33 -17
  24. data/lib/utilities/{flows → schedules}/os/task_scheduler.rb +36 -20
  25. data/lib/utilities/schedules/os_enumerator.rb +30 -0
  26. data/lib/utilities/schedules/project.rb +70 -0
  27. data/lib/utilities/schedules/rule.rb +162 -0
  28. data/lib/utilities/schedules/run_lock.rb +160 -0
  29. data/lib/utilities/schedules/run_log.rb +122 -0
  30. data/lib/utilities/schedules/run_report.rb +139 -0
  31. data/lib/utilities/schedules/run_wrapper.rb +216 -0
  32. data/lib/utilities/schedules/schedule.rb +89 -0
  33. data/lib/utilities/schedules/schedule_store.rb +53 -0
  34. data/lib/utilities/schedules/scheduler.rb +178 -0
  35. data/lib/utilities/schedules/scheduler_steps.rb +69 -0
  36. data/lib/utilities/schedules/selection.rb +133 -0
  37. data/lib/utilities/schedules/spawn.rb +68 -0
  38. data/lib/utilities/{flows/schedule_store.rb → schedules/store.rb} +17 -13
  39. data/lib/version +1 -1
  40. data/ruby_raider.gemspec +1 -0
  41. data/sig/commands/schedule_commands.rbs +12 -0
  42. data/sig/ruby_raider/runtime/retry_reset.rbs +7 -0
  43. data/sig/ruby_raider/runtime/show_browser.rbs +27 -0
  44. data/sig/ruby_raider.rbs +4 -0
  45. data/sig/utilities/schedules/adapter.rbs +5 -0
  46. data/sig/utilities/schedules/base.rbs +15 -0
  47. data/sig/utilities/schedules/kind.rbs +32 -0
  48. data/sig/utilities/schedules/legacy.rbs +45 -0
  49. data/sig/utilities/schedules/legacy_stub.rbs +15 -0
  50. data/sig/utilities/{flows → schedules}/next_run.rbs +3 -3
  51. data/sig/utilities/{flows → schedules}/notifier.rbs +1 -1
  52. data/sig/utilities/{flows → schedules}/os/command_runner.rbs +1 -1
  53. data/sig/utilities/{flows → schedules}/os/cron.rbs +7 -7
  54. data/sig/utilities/{flows → schedules}/os/launchd.rbs +7 -6
  55. data/sig/utilities/{flows → schedules}/os/linux.rbs +10 -4
  56. data/sig/utilities/{flows → schedules}/os/systemd.rbs +8 -7
  57. data/sig/utilities/{flows → schedules}/os/task_scheduler.rbs +13 -10
  58. data/sig/utilities/schedules/os_enumerator.rbs +12 -0
  59. data/sig/utilities/schedules/project.rbs +22 -0
  60. data/sig/utilities/{flows/schedule.rbs → schedules/rule.rbs} +23 -12
  61. data/sig/utilities/schedules/run_lock.rbs +29 -0
  62. data/sig/utilities/schedules/run_log.rbs +33 -0
  63. data/sig/utilities/schedules/run_report.rbs +34 -0
  64. data/sig/utilities/schedules/run_wrapper.rbs +45 -0
  65. data/sig/utilities/schedules/schedule.rbs +27 -0
  66. data/sig/utilities/schedules/schedule_store.rbs +21 -0
  67. data/sig/utilities/{flows → schedules}/scheduler.rbs +22 -15
  68. data/sig/utilities/schedules/scheduler_steps.rbs +22 -0
  69. data/sig/utilities/schedules/selection.rbs +36 -0
  70. data/sig/utilities/schedules/spawn.rbs +10 -0
  71. data/sig/utilities/{flows/schedule_store.rbs → schedules/store.rbs} +14 -9
  72. data/spec/commands/schedule_commands_spec.rb +284 -0
  73. data/spec/integration/content/common_content_spec.rb +8 -2
  74. data/spec/integration/content/gemfile_content_spec.rb +2 -2
  75. data/spec/integration/end_to_end_spec.rb +0 -63
  76. data/spec/runtime/retry_reset_spec.rb +112 -0
  77. data/spec/runtime/show_browser_project_spec.rb +70 -0
  78. data/spec/runtime/show_browser_spec.rb +101 -0
  79. data/spec/utilities/schedules/kind_spec.rb +81 -0
  80. data/spec/utilities/schedules/legacy_spec.rb +433 -0
  81. data/spec/utilities/schedules/legacy_stub_spec.rb +144 -0
  82. data/spec/utilities/{flows → schedules}/next_run_spec.rb +5 -5
  83. data/spec/utilities/{flows → schedules}/notifier_spec.rb +3 -4
  84. data/spec/utilities/{flows → schedules}/os/command_runner_spec.rb +2 -3
  85. data/spec/utilities/{flows → schedules}/os/cron_spec.rb +34 -29
  86. data/spec/utilities/{flows → schedules}/os/launchd_spec.rb +42 -29
  87. data/spec/utilities/{flows → schedules}/os/linux_spec.rb +27 -13
  88. data/spec/utilities/schedules/os/list_ids_spec.rb +202 -0
  89. data/spec/utilities/{flows → schedules}/os/systemd_spec.rb +35 -31
  90. data/spec/utilities/{flows → schedules}/os/task_scheduler_spec.rb +40 -34
  91. data/spec/utilities/schedules/os_entry_spec.rb +54 -0
  92. data/spec/utilities/schedules/os_enumerator_spec.rb +57 -0
  93. data/spec/utilities/schedules/project_spec.rb +125 -0
  94. data/spec/utilities/schedules/run_lock_spec.rb +143 -0
  95. data/spec/utilities/schedules/run_log_spec.rb +83 -0
  96. data/spec/utilities/schedules/run_report_spec.rb +150 -0
  97. data/spec/utilities/schedules/run_wrapper_integration_spec.rb +166 -0
  98. data/spec/utilities/schedules/run_wrapper_spec.rb +430 -0
  99. data/spec/utilities/schedules/schedule_spec.rb +120 -0
  100. data/spec/utilities/schedules/schedule_store_spec.rb +151 -0
  101. data/spec/utilities/schedules/scheduler_launchd_spec.rb +77 -0
  102. data/spec/utilities/schedules/scheduler_spec.rb +445 -0
  103. data/spec/utilities/schedules/selection_spec.rb +176 -0
  104. data/spec/utilities/schedules/spawn_spec.rb +104 -0
  105. metadata +106 -53
  106. data/lib/commands/flow_commands.rb +0 -104
  107. data/lib/generators/templates/flows/flow_helper.tt +0 -42
  108. data/lib/generators/templates/flows/run.tt +0 -332
  109. data/lib/utilities/flows/project.rb +0 -170
  110. data/lib/utilities/flows/run_log.rb +0 -61
  111. data/lib/utilities/flows/runner.rb +0 -206
  112. data/lib/utilities/flows/schedule.rb +0 -221
  113. data/lib/utilities/flows/scheduler.rb +0 -187
  114. data/sig/commands/flow_commands.rbs +0 -13
  115. data/sig/utilities/flows/project.rbs +0 -46
  116. data/sig/utilities/flows/run_log.rbs +0 -22
  117. data/sig/utilities/flows/runner.rbs +0 -35
  118. data/spec/commands/flow_commands_spec.rb +0 -128
  119. data/spec/generators/flow_helper_spec.rb +0 -40
  120. data/spec/integration/flows/flow_project.rb +0 -62
  121. data/spec/integration/flows/run_command_spec.rb +0 -91
  122. data/spec/integration/flows/run_lifecycle_spec.rb +0 -383
  123. data/spec/utilities/flows/project_spec.rb +0 -178
  124. data/spec/utilities/flows/run_log_spec.rb +0 -47
  125. data/spec/utilities/flows/runner_spec.rb +0 -313
  126. data/spec/utilities/flows/schedule_spec.rb +0 -233
  127. data/spec/utilities/flows/schedule_store_spec.rb +0 -137
  128. data/spec/utilities/flows/scheduler_spec.rb +0 -329
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require_relative 'adapter'
5
+ require_relative 'os_enumerator'
6
+ require_relative 'rule'
7
+
8
+ module RubyRaider
9
+ module Schedules
10
+ # Removes the schedules Ruby Raider 4.1 made in a project (Kind::FLOW_LEGACY): they can't run in 4.2.
11
+ # Every `raider schedule` command runs it first, and so does the hidden stub an old OS entry reaches.
12
+ # It only ever acts on this project's os_ids, each recomputed from the project's real path and a name
13
+ # (from the old schedules.yml or the OS scheduler); an os_id stored in the yml is never used. It deletes
14
+ # the old schedules.yml once every entry is gone, and nothing else, and only when the file is a 4.1
15
+ # schedules file all of whose entries are this project's: a file it can't read, or one with an entry
16
+ # of another path (a moved project), stays, since its os_ids are what the user needs to remove by hand.
17
+ # An adapter's error text is passed through as is on purpose: it names the os_id the README says to look for.
18
+ # Never raises.
19
+ # :reek:TooManyConstants { max_constants: 6 } -- the kind, file, size cap, open flags and the old rule
20
+ # :reek:TooManyInstanceVariables { max_instance_variables: 5 } -- the project, its root, adapter, stream and whether the yml may go
21
+ class Legacy
22
+ KIND = Kind::FLOW_LEGACY
23
+ FILE = 'schedules.yml'
24
+ MAX_BYTES = 1_000_000
25
+ NOFOLLOW = defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0
26
+ NONBLOCK = defined?(File::NONBLOCK) ? File::NONBLOCK : 0
27
+
28
+ # The 4.1 rule, for its os_ids only (os_id_for, os_id_prefix_for)
29
+ class OldRule < Rule
30
+ def self.kind = KIND
31
+ end
32
+
33
+ # project: a Project (its real root); adapter: nil picks this machine's OS scheduler when first needed
34
+ def initialize(project, adapter: nil, err: $stderr)
35
+ @project = project
36
+ @root = project.root
37
+ @adapter = adapter
38
+ @err = err
39
+ @yml_clean = false
40
+ end
41
+
42
+ # True when the project has a real .raider/flows folder (4.1 always made one; this never deletes it):
43
+ # without one there is nothing to clean, so `raider schedule` skips the cleanup for one lstat
44
+ def self.folder?(project)
45
+ File.lstat(File.join(project.root, KIND.dir)).directory?
46
+ rescue SystemCallError
47
+ false
48
+ end
49
+
50
+ # Removes every 4.1 schedule of the project, then its schedules.yml. Prints and returns the notice, nil
51
+ # when there was nothing to remove. last: the name of the entry running this (the stub's own), removed
52
+ # after the notice is written and the yml deleted, since removing it may end this process.
53
+ def cleanup(last: nil)
54
+ current = adapter
55
+ return nil unless current
56
+
57
+ names = (yml_names | OsEnumerator.new(current, schedule_class: OldRule, root: @root).names).sort
58
+ own = names.delete(last) if last
59
+ removed, failed = remove_all(current, names)
60
+ removed << own if own
61
+ delete_yml if failed.empty?
62
+ notice = notice(removed, failed)
63
+ say(notice) if notice
64
+ remove_own(current, own) if own
65
+ notice
66
+ rescue StandardError => e
67
+ say("Could not remove the schedules made with Ruby Raider 4.1: #{e.message}")
68
+ end
69
+
70
+ private
71
+
72
+ def adapter
73
+ @adapter ||= Schedules.adapter(kind: KIND)
74
+ rescue Error
75
+ nil
76
+ end
77
+
78
+ # [removed names, [name, message] of each that failed]; one already gone is neither
79
+ def remove_all(current, names)
80
+ removed = []
81
+ failed = []
82
+ names.each do |name|
83
+ removed << name if current.remove(OldRule.os_id_for(@root, name))
84
+ rescue Error, SystemCallError => e
85
+ failed << [name, e.message]
86
+ end
87
+ [removed, failed]
88
+ end
89
+
90
+ # Its own entry last: the notice is out (and flushed) and the yml gone before this may end the process
91
+ def remove_own(current, name)
92
+ current.remove(OldRule.os_id_for(@root, name))
93
+ rescue Error, SystemCallError => e
94
+ say("Could not remove #{name}: #{e.message}")
95
+ end
96
+
97
+ def notice(removed, failed)
98
+ lines = []
99
+ lines << removed_text(removed) unless removed.empty?
100
+ lines << failed_text(failed) unless failed.empty?
101
+ lines.empty? ? nil : Schedules.printable(lines.join(' '))
102
+ end
103
+
104
+ def removed_text(names)
105
+ one = names.size == 1
106
+ "Removed #{counted(names.size)} made with Ruby Raider 4.1 (#{names.join(', ')}); #{one ? 'it' : 'they'} can't run in 4.2. " \
107
+ "Add #{one ? 'it' : 'them'} again with raider schedule add."
108
+ end
109
+
110
+ def failed_text(failed)
111
+ reasons = failed.map { |name, message| "#{name}: #{message}" }.join(', ')
112
+ "Could not remove #{counted(failed.size)} made with Ruby Raider 4.1 (#{reasons}); " \
113
+ "#{failed.size == 1 ? 'it is' : 'they are'} tried again next time."
114
+ end
115
+
116
+ def counted(count) = count == 1 ? '1 schedule' : "#{count} schedules"
117
+
118
+ # One printable line on err, flushed; returns it
119
+ def say(text)
120
+ line = Schedules.printable(text)
121
+ err = @err
122
+ err.puts(line)
123
+ err.flush if err.respond_to?(:flush)
124
+ line
125
+ rescue StandardError
126
+ line
127
+ end
128
+
129
+ def dir = File.join(@root, KIND.dir)
130
+
131
+ def yml = File.join(dir, FILE)
132
+
133
+ # .raider and .raider/flows are real folders, not links (a missing one is one failed lstat)
134
+ def real_dirs? = [File.join(@root, '.raider'), dir].all? { |path| File.lstat(path).directory? }
135
+
136
+ # The names of the yml's entries that 4.1 would read as this project's: valid, with the os_id it
137
+ # computes for this project; anything else in the file, or a file that is not a schedules file, gives
138
+ # none. The yml may go only when it was a schedules file and every entry was one of these.
139
+ def yml_names
140
+ entries = read_entries
141
+ return [] unless entries
142
+
143
+ names = entries.map { |entry| entry_name(entry) }
144
+ @yml_clean = names.all?
145
+ names.compact.uniq
146
+ end
147
+
148
+ def entry_name(entry)
149
+ return nil unless entry.is_a?(Hash)
150
+
151
+ name = entry['name']
152
+ name if name.is_a?(String) && KIND.valid_name?(name) && entry['os_id'] == OldRule.os_id_for(@root, name)
153
+ end
154
+
155
+ # The entries of a schedules file ([] for an empty document); nil when there is none or it is not one
156
+ def read_entries
157
+ text = read_text
158
+ return nil unless text
159
+
160
+ data = YAML.safe_load(text)
161
+ return [] unless data
162
+
163
+ entries = data.is_a?(Hash) ? data['schedules'] : nil
164
+ entries.is_a?(Array) ? entries : nil
165
+ rescue Psych::Exception
166
+ nil
167
+ end
168
+
169
+ # The yml through one handle that is not a link, a regular file up to MAX_BYTES; nil otherwise.
170
+ # The folders are lstat'ed and then the file opened (and later unlinked) by path, so a folder swapped
171
+ # for a link in between is followed: that takes write access to the project, and Ruby has no
172
+ # openat/unlinkat to close the window.
173
+ def read_text
174
+ return nil unless real_dirs?
175
+
176
+ File.open(yml, File::RDONLY | NONBLOCK | NOFOLLOW) do |file|
177
+ next nil unless file.stat.file?
178
+
179
+ text = file.read(MAX_BYTES + 1) || +'' # an empty file reads nil
180
+ text.bytesize > MAX_BYTES ? nil : text.force_encoding(Encoding::UTF_8)
181
+ end
182
+ rescue SystemCallError
183
+ nil
184
+ end
185
+
186
+ # The yml only, when it was read as a clean schedules file and the folders and it still are real
187
+ # (a link or folder stays)
188
+ def delete_yml
189
+ path = yml
190
+ File.unlink(path) if @yml_clean && real_dirs? && File.lstat(path).file?
191
+ rescue SystemCallError
192
+ nil
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'legacy'
4
+ require_relative 'project'
5
+
6
+ module RubyRaider
7
+ module Schedules
8
+ # What a Ruby Raider 4.1 OS entry reaches now: `raider flow run NAME --project ROOT --trigger schedule ...`.
9
+ # Raider.start sends it here before Thor, so any argv works. It removes the project's 4.1 schedules (its
10
+ # own entry last), writes one line to stderr (the entry's scheduler.log) and always exits 2. It never
11
+ # sends a notification and never takes the project from the current folder.
12
+ module LegacyStub
13
+ EXIT = 2
14
+ REMOVED = 'This command was removed in Ruby Raider 4.2. To run tests on a schedule, use raider schedule add.'
15
+ OPTION = '--project'
16
+
17
+ # The exit code: always 2
18
+ def self.run(argv, err: $stderr)
19
+ root = project_root(argv)
20
+ root ? clean(root, own_name(argv), err) : err.puts(REMOVED)
21
+ EXIT
22
+ rescue StandardError
23
+ EXIT
24
+ end
25
+
26
+ def self.clean(root, name, err)
27
+ return if Legacy.new(Project.new(root), err: err).cleanup(last: name)
28
+
29
+ err.puts(Schedules.printable("This schedule was made with Ruby Raider 4.1 and can't run in 4.2, and none was found for #{root} " \
30
+ 'to remove: see Upgrading from 4.1 in the README.'))
31
+ end
32
+
33
+ # The real path of the first --project value: valid UTF-8, absolute, a folder with a regular Gemfile
34
+ def self.project_root(argv)
35
+ path = project_value(argv)
36
+ return nil unless path.is_a?(String) && path.valid_encoding? && !path.include?("\0") && File.absolute_path?(path)
37
+
38
+ root = File.realpath(path)
39
+ File.directory?(root) && File.lstat(File.join(root, 'Gemfile')).file? ? root : nil
40
+ rescue SystemCallError
41
+ nil
42
+ end
43
+
44
+ # --project VALUE or --project=VALUE, whichever comes first
45
+ def self.project_value(argv)
46
+ argv.each_with_index do |arg, index|
47
+ next unless arg.is_a?(String) && arg.valid_encoding?
48
+ return argv[index + 1] if arg == OPTION
49
+ return arg.delete_prefix("#{OPTION}=") if arg.start_with?("#{OPTION}=")
50
+ end
51
+ nil
52
+ end
53
+
54
+ # The name after the first run, when it is a valid 4.1 name
55
+ def self.own_name(argv)
56
+ index = argv.index('run')
57
+ name = index && argv[index + 1]
58
+ name if name.is_a?(String) && name.valid_encoding? && Legacy::KIND.valid_name?(name)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'date'
4
- require_relative 'schedule'
4
+ require_relative 'base'
5
5
 
6
6
  module RubyRaider
7
- module Flows
7
+ module Schedules
8
8
  # When a schedule next runs, in local time, as launchd, schtasks, systemd and cron count it.
9
9
  # A day, weekday or week rule whose time falls in a spring-forward gap runs at the first minute
10
10
  # after the gap; one that falls in a fall-back hour runs at its first occurrence only.
@@ -1,14 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rbconfig'
4
- require_relative 'project'
4
+ require_relative 'base'
5
5
  require_relative 'os/command_runner'
6
6
 
7
7
  module RubyRaider
8
- module Flows
9
- # A desktop notification for `raider flow run --notify`. The title and message only ever travel as argv
8
+ module Schedules
9
+ # A desktop notification for a scheduled run with --notify. The title and message only ever travel as argv
10
10
  # items after `--` (osascript, notify-send: so a title like -e is not read as an option or a script) or as
11
- # environment values (PowerShell), so a hostile flow name or step title can't run as AppleScript or PowerShell. Never raises: a notification
11
+ # environment values (PowerShell), so a hostile schedule name or test title can't run as AppleScript or PowerShell. Never raises: a notification
12
12
  # that can't be shown must not change how the run ends.
13
13
  # :reek:TooManyConstants { max_constants: 7 } -- the fixed command of each OS, the message cap and the timeout
14
14
  # :reek:TooManyInstanceVariables { max_instance_variables: 5 } -- the injected runner, OS, stream and PowerShell path, and the warned flag
@@ -64,10 +64,10 @@ module RubyRaider
64
64
 
65
65
  private
66
66
 
67
- def clean(text) = Flows.printable(text)[0, MAX].to_s
67
+ def clean(text) = Schedules.printable(text)[0, MAX].to_s
68
68
 
69
69
  def failed(reason)
70
- @err.puts("Could not show a desktop notification: #{Flows.printable(reason.strip)}") unless @warned
70
+ @err.puts("Could not show a desktop notification: #{Schedules.printable(reason.strip)}") unless @warned
71
71
  @warned = true
72
72
  false
73
73
  end
@@ -3,7 +3,7 @@
3
3
  require 'open3'
4
4
 
5
5
  module RubyRaider
6
- module Flows
6
+ module Schedules
7
7
  module Os
8
8
  # Runs an OS scheduler command (launchctl, schtasks, systemctl, crontab) with no shell. Adapters
9
9
  # take one, so tests pass a double and never touch the real scheduler.
@@ -2,22 +2,22 @@
2
2
 
3
3
  require 'shellwords'
4
4
  require_relative 'command_runner'
5
- require_relative '../schedule'
5
+ require_relative '../base'
6
6
 
7
7
  module RubyRaider
8
- module Flows
8
+ module Schedules
9
9
  module Os
10
10
  # Linux without a systemd user manager: one tagged block per schedule in the user's crontab, read with
11
11
  # `crontab -l` and written back with `crontab -`. Every value goes through Shellwords.escape, which also
12
12
  # escapes % as \% (cron would turn a bare % into a newline). Lines that are not ours keep every byte.
13
13
  class Cron
14
14
  CRONTAB = 'crontab'
15
- TAG = '# raider-flow '
16
15
  # Debian's vixie cron cuts a command at MAX_COMMAND (1000) bytes without a word; stay under it
17
16
  MAX_LINE = 990
18
- OS_ID = /\A#{Regexp.escape(Schedule::OS_ID_PREFIX)}\.[0-9a-f]{10}\.([a-z0-9_]{1,60})\z/
19
17
 
20
- def initialize(runner: CommandRunner.new)
18
+ # kind: the Kind whose entries this crontab block holds
19
+ def initialize(kind:, runner: CommandRunner.new)
20
+ @kind = kind
21
21
  @runner = runner
22
22
  end
23
23
 
@@ -28,7 +28,7 @@ module RubyRaider
28
28
  # Minute hour day-of-month month day-of-week (the system time zone), then the command for /bin/sh
29
29
  def line(schedule)
30
30
  ruby = schedule.ruby
31
- raise Error, "cron can not run ruby from #{Flows.printable(ruby)}: env would read the = as a variable" if ruby.include?('=')
31
+ raise Error, "cron can not run ruby from #{Schedules.printable(ruby)}: env would read the = as a variable" if ruby.include?('=')
32
32
 
33
33
  env = schedule.env.map { |key, value| "#{key}=#{value}" }
34
34
  command = ['cd', escape(schedule.working_directory), '&&', '/usr/bin/env', *[*env, *schedule.argv].map { |word| escape(word) },
@@ -43,7 +43,7 @@ module RubyRaider
43
43
  raise Error, "cron can not run #{schedule.name}: its crontab line is #{entry.bytesize} bytes, over #{MAX_LINE}" if entry.bytesize > MAX_LINE
44
44
 
45
45
  rest, = without(read, os_id)
46
- save("#{TAG}#{os_id}\n#{entry}\n#{rest}")
46
+ save("#{@kind.cron_tag}#{os_id}\n#{entry}\n#{rest}")
47
47
  true
48
48
  end
49
49
 
@@ -60,6 +60,16 @@ module RubyRaider
60
60
  without(read, tag).last
61
61
  end
62
62
 
63
+ # The os_ids of this kind starting with prefix on our tag lines, sorted
64
+ def list_ids(prefix)
65
+ tag = @kind.cron_tag
66
+ read.dup.force_encoding(Encoding::UTF_8).scrub.each_line.filter_map do |text|
67
+ line = text.chomp
68
+ id = line.delete_prefix(tag)
69
+ id if id != line && @kind.listed?(id, prefix)
70
+ end.uniq.sort
71
+ end
72
+
63
73
  # True when crontab -l answers (an empty crontab counts)
64
74
  def usable?
65
75
  read
@@ -84,7 +94,7 @@ module RubyRaider
84
94
 
85
95
  # cron counts Sunday as 0
86
96
  def weekday(day)
87
- index = Schedule::DAYS.index(day)
97
+ index = DAYS.index(day)
88
98
  raise Error, "Invalid day #{day.inspect}" unless index
89
99
 
90
100
  (index + 1) % 7
@@ -95,8 +105,8 @@ module RubyRaider
95
105
  # The crontab without any of our tag lines, each with the line after it when that line is our command,
96
106
  # and whether a tag was there
97
107
  def without(table, os_id)
98
- tag = "#{TAG}#{os_id}"
99
- name = OS_ID.match(os_id).to_a[1]
108
+ tag = "#{@kind.cron_tag}#{os_id}"
109
+ name = @kind.os_id_pattern.match(os_id).to_a[1]
100
110
  kept = []
101
111
  found = after_tag = false
102
112
  table.lines.each do |text|
@@ -108,28 +118,28 @@ module RubyRaider
108
118
  [kept.join, found]
109
119
  end
110
120
 
111
- # The line under our tag runs `raider flow run <name> --project ... --trigger schedule`
112
- def our_command?(text, name) = text.include?(" exec raider flow run #{name} --project ") && text.include?(' --trigger schedule')
121
+ # The line under our tag runs `raider <command> run <name> --project ... --trigger schedule`
122
+ def our_command?(text, name) = @kind.our_command?(text, name)
113
123
 
114
124
  def read
115
125
  result = @runner.call(CRONTAB, '-l')
116
126
  return result.stdout if result.success?
117
127
 
118
- stderr = result.stderr
128
+ stderr = result.stderr.scrub('?')
119
129
  return '' if stderr.match?(/no crontab/i)
120
130
 
121
- raise Error, "cron could not read the crontab: #{Flows.printable(stderr.strip)}"
131
+ raise Error, "cron could not read the crontab: #{Schedules.printable(stderr.strip)}"
122
132
  end
123
133
 
124
134
  def save(table)
125
135
  result = @runner.call(CRONTAB, '-', stdin_data: table)
126
- raise Error, "cron could not save the crontab: #{Flows.printable(result.stderr.strip)}" unless result.success?
136
+ raise Error, "cron could not save the crontab: #{Schedules.printable(result.stderr.strip)}" unless result.success?
127
137
  end
128
138
 
129
139
  def checked_os_id(os_id)
130
- return os_id if os_id.is_a?(String) && (match = OS_ID.match(os_id)) && Flows.valid_name?(match[1])
140
+ return os_id if @kind.name_in(os_id)
131
141
 
132
- raise Error, "Invalid os_id #{Flows.printable(os_id.inspect)}: not a flow schedule"
142
+ raise Error, "Invalid os_id #{Schedules.printable(os_id.inspect)}: #{@kind.not_ours}"
133
143
  end
134
144
  end
135
145
  end
@@ -3,14 +3,14 @@
3
3
  require 'fileutils'
4
4
  require 'securerandom'
5
5
  require_relative 'command_runner'
6
- require_relative '../schedule'
6
+ require_relative '../base'
7
7
 
8
8
  module RubyRaider
9
- module Flows
9
+ module Schedules
10
10
  module Os
11
11
  # macOS: a LaunchAgent plist in ~/Library/LaunchAgents per schedule, loaded into the user's GUI
12
12
  # domain with launchctl. Every value in the plist goes through one XML escaper.
13
- # :reek:TooManyConstants { max_constants: 6 } -- the two tools, NOFOLLOW, the XML escapes and header, the os_id rule
13
+ # :reek:TooManyConstants { max_constants: 5 } -- the two tools, NOFOLLOW, the XML escapes and header
14
14
  class Launchd
15
15
  LAUNCHCTL = '/bin/launchctl'
16
16
  PLUTIL = '/usr/bin/plutil'
@@ -21,9 +21,10 @@ module RubyRaider
21
21
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
22
22
  <plist version="1.0">
23
23
  XML
24
- OS_ID = /\A#{Regexp.escape(Schedule::OS_ID_PREFIX)}\.[0-9a-f]{10}\.([a-z0-9_]{1,60})\z/
25
24
 
26
- def initialize(runner: CommandRunner.new, home: Dir.home, uid: Process.uid)
25
+ # kind: the Kind whose LaunchAgents this adapter writes and removes
26
+ def initialize(kind:, runner: CommandRunner.new, home: Dir.home, uid: Process.uid)
27
+ @kind = kind
27
28
  @runner = runner
28
29
  @home = home
29
30
  @uid = uid
@@ -57,18 +58,35 @@ module RubyRaider
57
58
  result = launchctl('bootstrap', domain, path)
58
59
  return true if result.success?
59
60
 
60
- raise Error, "launchd could not load #{os_id}: #{Flows.printable(result.stderr.strip)}#{discard(path)}"
61
+ raise Error, "launchd could not load #{os_id}: #{Schedules.printable(result.stderr.strip)}#{discard(path)}"
61
62
  end
62
63
 
63
- # True when a plist or a loaded service was removed, false when it was already gone
64
+ # True when a plist or a loaded service was removed, false when it was already gone. The plist goes
65
+ # first: a job removing itself is ended by its own bootout, and its plist must be gone by then.
66
+ # If the unlink succeeds and the bootout fails, the job stays loaded with no plist until logout.
64
67
  def remove(os_id)
65
- path = plist_path(os_id)
68
+ unlinked = unlink(plist_path(os_id))
66
69
  unloaded = launchctl('bootout', service(os_id)).success?
67
- unlink(path) || unloaded
70
+ unlinked || unloaded
68
71
  end
69
72
 
70
73
  def present?(os_id) = launchctl('print', service(checked_os_id(os_id))).success?
71
74
 
75
+ # The os_ids of this kind starting with prefix that have a plist, sorted; runs nothing
76
+ def list_ids(prefix)
77
+ dir = agents_dir
78
+ Dir.children(dir).filter_map do |file|
79
+ next unless file.valid_encoding? && file.end_with?('.plist')
80
+
81
+ id = file.delete_suffix('.plist')
82
+ id if @kind.listed?(id, prefix)
83
+ end.sort
84
+ rescue Errno::ENOENT
85
+ []
86
+ rescue SystemCallError => e
87
+ raise Error, "Could not read #{dir}: #{e.message}"
88
+ end
89
+
72
90
  private
73
91
 
74
92
  def launchctl(*args) = @runner.call(LAUNCHCTL, *args)
@@ -90,9 +108,9 @@ module RubyRaider
90
108
  def agents_dir = File.join(@home, 'Library', 'LaunchAgents')
91
109
 
92
110
  def checked_os_id(os_id)
93
- return os_id if os_id.is_a?(String) && (match = OS_ID.match(os_id)) && Flows.valid_name?(match[1])
111
+ return os_id if @kind.name_in(os_id)
94
112
 
95
- raise Error, "Invalid os_id #{Flows.printable(os_id.inspect)}: not a flow schedule"
113
+ raise Error, "Invalid os_id #{Schedules.printable(os_id.inspect)}: #{@kind.not_ours}"
96
114
  end
97
115
 
98
116
  def escape(text) = text.to_s.gsub(/[&<>"']/, ESCAPES)
@@ -110,7 +128,7 @@ module RubyRaider
110
128
 
111
129
  def indent(lines) = lines.map { |line| "\t#{line}" }
112
130
 
113
- # Schedule::DAYS runs mon..sun
131
+ # DAYS runs mon..sun
114
132
  def calendar(schedule)
115
133
  time = [['Hour', integer(schedule.hour)], ['Minute', integer(schedule.minute)]]
116
134
  case schedule.every
@@ -124,7 +142,7 @@ module RubyRaider
124
142
 
125
143
  # launchd counts Sunday as 0
126
144
  def weekday(day)
127
- index = Schedule::DAYS.index(day)
145
+ index = DAYS.index(day)
128
146
  raise Error, "Invalid day #{day.inspect}" unless index
129
147
 
130
148
  (index + 1) % 7
@@ -150,7 +168,7 @@ module RubyRaider
150
168
  file.chmod(0o644)
151
169
  end
152
170
  lint = @runner.call(PLUTIL, '-lint', temp)
153
- raise Error, "launchd plist for #{File.basename(path)} is invalid: #{Flows.printable("#{lint.stdout} #{lint.stderr}".strip)}" unless lint.success?
171
+ raise Error, "launchd plist for #{File.basename(path)} is invalid: #{Schedules.printable("#{lint.stdout} #{lint.stderr}".strip)}" unless lint.success?
154
172
 
155
173
  File.rename(temp, path)
156
174
  rescue SystemCallError => e
@@ -5,16 +5,18 @@ require_relative 'systemd'
5
5
  require_relative 'cron'
6
6
 
7
7
  module RubyRaider
8
- module Flows
8
+ module Schedules
9
9
  module Os
10
10
  # Linux: systemd user timers when the user's systemd manager answers, cron otherwise. A schedule made over
11
11
  # ssh (no user bus, so cron) must still go when unscheduled from a desktop session, so remove and present?
12
12
  # also look in the other scheduler when it answers. The reverse (a systemd timer, unscheduled where no user
13
13
  # bus answers) can only be seen from its unit files, and can only be removed where systemctl --user works.
14
14
  class Linux
15
- def self.pick(runner = CommandRunner.new) = new(runner: runner)
15
+ def self.pick(runner = CommandRunner.new, kind:) = new(kind: kind, runner: runner)
16
16
 
17
- def initialize(runner: CommandRunner.new, systemd: Systemd.new(runner: runner), cron: Cron.new(runner: runner))
17
+ # kind: the Kind of the schedules this adapter installs and removes
18
+ def initialize(kind:, runner: CommandRunner.new, systemd: Systemd.new(kind: kind, runner: runner), cron: Cron.new(kind: kind, runner: runner))
19
+ @kind = kind
18
20
  # systemd is the other one only when it answers, so with cron first there is no other one
19
21
  @systemd = systemd
20
22
  @primary, @other = systemd.usable? ? [systemd, cron] : [cron, nil]
@@ -38,16 +40,32 @@ module RubyRaider
38
40
  # True when either scheduler has it, or systemd unit files are there though systemd does not answer
39
41
  def present?(os_id) = @primary.present?(os_id) || other_present?(os_id) || stranded?(os_id)
40
42
 
43
+ # Both schedulers' ids plus systemd unit files (there even when systemd does not answer); only the
44
+ # primary scheduler failing is an error
45
+ def list_ids(prefix)
46
+ (@primary.list_ids(prefix) + other_ids(prefix) + quietly { @systemd.list_ids(prefix) }).uniq.sort
47
+ end
48
+
41
49
  private
42
50
 
51
+ def other_ids(prefix)
52
+ other = @other
53
+ other&.usable? ? quietly { other.list_ids(prefix) } : []
54
+ end
55
+
56
+ def quietly
57
+ yield
58
+ rescue Error
59
+ []
60
+ end
61
+
43
62
  def stranded?(os_id) = @primary != @systemd && @systemd.unit_files?(os_id)
44
63
 
45
64
  # Raises so the schedules.yml entry stays until the timer can really go
46
65
  def refuse_stranded(os_id)
47
66
  return unless stranded?(os_id)
48
67
 
49
- raise Error, "This schedule is a systemd timer; run raider flow unschedule #{os_id.split('.').last} " \
50
- 'from a session where systemctl --user works'
68
+ raise Error, @kind.stranded(os_id.split('.').last)
51
69
  end
52
70
 
53
71
  # The other scheduler failing counts as not there