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
@@ -3,25 +3,26 @@
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
  # Linux: a oneshot service and a persistent timer per schedule in the user's systemd folder, enabled
12
12
  # with systemctl --user. Words in ExecStart= and Environment= are double-quoted with C escapes; every
13
13
  # value has % doubled (specifiers) and ExecStart= also has $ doubled (variable expansion).
14
- # :reek:TooManyConstants { max_constants: 6 } -- the tool, NOFOLLOW, the escapes, the calendar days, the os_id and path rules
14
+ # :reek:TooManyConstants { max_constants: 5 } -- the tool, NOFOLLOW, the escapes, the calendar days, the path rule
15
15
  class Systemd
16
16
  SYSTEMCTL = 'systemctl'
17
17
  NOFOLLOW = defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0
18
18
  QUOTED = { '\\' => '\\\\', '"' => '\\"', '%' => '%%' }.freeze
19
19
  CALENDAR = { 'hour' => '', 'day' => '', 'weekday' => 'Mon..Fri ' }.freeze
20
- OS_ID = /\A#{Regexp.escape(Schedule::OS_ID_PREFIX)}\.[0-9a-f]{10}\.([a-z0-9_]{1,60})\z/
21
20
  # The unit parser strips whitespace at the ends of a value and reads a trailing backslash as "continued"
22
21
  UNSAFE_PATH = /\s\z|\\\z/
23
22
 
24
- def initialize(runner: CommandRunner.new, home: Dir.home, config_home: ENV.fetch('XDG_CONFIG_HOME', nil))
23
+ # kind: the Kind whose units this adapter writes and removes
24
+ def initialize(kind:, runner: CommandRunner.new, home: Dir.home, config_home: ENV.fetch('XDG_CONFIG_HOME', nil))
25
+ @kind = kind
25
26
  @runner = runner
26
27
  @home = home
27
28
  @config_home = config_home
@@ -37,7 +38,7 @@ module RubyRaider
37
38
  log = specifiers(schedule.log_path)
38
39
  <<~UNIT
39
40
  [Unit]
40
- Description=raider flow #{schedule.name}
41
+ Description=#{@kind.service_description(schedule.name)}
41
42
 
42
43
  [Service]
43
44
  Type=oneshot
@@ -56,7 +57,7 @@ module RubyRaider
56
57
  hour = every == 'hour' ? '*' : format('%02d', schedule.hour)
57
58
  <<~UNIT
58
59
  [Unit]
59
- Description=raider flow #{schedule.name} schedule
60
+ Description=#{@kind.timer_description(schedule.name)}
60
61
 
61
62
  [Timer]
62
63
  OnCalendar=#{day}*-*-* #{hour}:#{format('%02d', schedule.minute)}:00
@@ -67,7 +68,7 @@ module RubyRaider
67
68
  UNIT
68
69
  end
69
70
 
70
- def unit_path(os_id, kind) = File.join(units_dir, "#{checked_os_id(os_id)}.#{kind}")
71
+ def unit_path(os_id, unit) = File.join(units_dir, "#{checked_os_id(os_id)}.#{unit}")
71
72
 
72
73
  # Writes both units (temp files renamed into place), reloads and enables the timer; a failure leaves no unit behind
73
74
  def install(schedule)
@@ -77,25 +78,40 @@ module RubyRaider
77
78
  failure = [systemctl('daemon-reload'), systemctl('enable', '--now', "#{os_id}.timer")].find { |result| !result.success? }
78
79
  return true unless failure
79
80
 
80
- message = "systemd could not enable #{os_id}: #{Flows.printable(failure.stderr.strip)}"
81
+ message = "systemd could not enable #{os_id}: #{Schedules.printable(failure.stderr.strip)}"
81
82
  raise Error, "#{message}#{discard(os_id)}"
82
83
  end
83
84
 
84
85
  # True when a unit file was removed, false when it was already gone
85
86
  def remove(os_id)
86
- paths = %w[service timer].map { |kind| unit_path(os_id, kind) }
87
+ paths = %w[service timer].map { |unit| unit_path(os_id, unit) }
87
88
  systemctl('disable', '--now', "#{os_id}.timer")
88
89
  removed = paths.map { |path| unlink(path) }.any?
89
90
  reload = systemctl('daemon-reload')
90
- raise Error, "systemd could not reload after removing #{os_id}: #{Flows.printable(reload.stderr.strip)}" unless reload.success?
91
+ raise Error, "systemd could not reload after removing #{os_id}: #{Schedules.printable(reload.stderr.strip)}" unless reload.success?
91
92
 
92
93
  removed
93
94
  end
94
95
 
95
96
  def present?(os_id) = systemctl('is-enabled', "#{checked_os_id(os_id)}.timer").success?
96
97
 
98
+ # The os_ids of this kind starting with prefix that have a unit file, sorted (no user manager needed)
99
+ def list_ids(prefix)
100
+ dir = units_dir
101
+ Dir.children(dir).filter_map do |file|
102
+ next unless file.valid_encoding?
103
+
104
+ id = file.sub(/\.(?:service|timer)\z/, '')
105
+ id if id != file && @kind.listed?(id, prefix)
106
+ end.uniq.sort
107
+ rescue Errno::ENOENT
108
+ []
109
+ rescue SystemCallError => e
110
+ raise Error, "Could not read #{dir}: #{e.message}"
111
+ end
112
+
97
113
  # True when a unit file for the schedule is on disk (no user manager needed)
98
- def unit_files?(os_id) = %w[service timer].any? { |kind| File.exist?(unit_path(os_id, kind)) }
114
+ def unit_files?(os_id) = %w[service timer].any? { |unit| File.exist?(unit_path(os_id, unit)) }
99
115
 
100
116
  # True when the user's systemd manager answers
101
117
  def usable? = systemctl('show-environment').success?
@@ -111,13 +127,13 @@ module RubyRaider
111
127
  end
112
128
 
113
129
  def checked_os_id(os_id)
114
- return os_id if os_id.is_a?(String) && (match = OS_ID.match(os_id)) && Flows.valid_name?(match[1])
130
+ return os_id if @kind.name_in(os_id)
115
131
 
116
- raise Error, "Invalid os_id #{Flows.printable(os_id.inspect)}: not a flow schedule"
132
+ raise Error, "Invalid os_id #{Schedules.printable(os_id.inspect)}: #{@kind.not_ours}"
117
133
  end
118
134
 
119
135
  def checked_path(path)
120
- raise Error, "systemd can not use the path #{Flows.printable(path.inspect)}: it ends in whitespace or a backslash" if UNSAFE_PATH.match?(path)
136
+ raise Error, "systemd can not use the path #{Schedules.printable(path.inspect)}: it ends in whitespace or a backslash" if UNSAFE_PATH.match?(path)
121
137
  end
122
138
 
123
139
  # A word systemd gives back exactly: double quotes, \\ and \" escapes, %% for %
@@ -139,9 +155,9 @@ module RubyRaider
139
155
  # Disables the timer, removes both units and reloads; returns what to add to the error when that fails too
140
156
  def discard(os_id)
141
157
  systemctl('disable', "#{os_id}.timer")
142
- %w[service timer].each { |kind| unlink(unit_path(os_id, kind)) }
158
+ %w[service timer].each { |unit| unlink(unit_path(os_id, unit)) }
143
159
  reload = systemctl('daemon-reload')
144
- reload.success? ? '' : "; also: reload failed: #{Flows.printable(reload.stderr.strip)}"
160
+ reload.success? ? '' : "; also: reload failed: #{Schedules.printable(reload.stderr.strip)}"
145
161
  rescue Error => e
146
162
  "; also: #{e.message}"
147
163
  end
@@ -2,30 +2,31 @@
2
2
 
3
3
  require 'tempfile'
4
4
  require_relative 'command_runner'
5
- require_relative '../schedule'
5
+ require_relative '../base'
6
6
  require_relative '../next_run'
7
7
 
8
8
  module RubyRaider
9
- module Flows
9
+ module Schedules
10
10
  module Os
11
11
  # Windows: one task per schedule in the \RubyRaider\ folder, registered from task XML with schtasks.
12
12
  # The task runs ruby.exe directly (never cmd.exe or powershell), with the user's own environment and
13
- # no log file: the outcome lives in runs.jsonl like any flow run. Argument values get Windows CRT
13
+ # no log file: the outcome lives in runs.jsonl like any scheduled run. Argument values get Windows CRT
14
14
  # quoting, then every value in the XML goes through one XML escaper.
15
15
  # Task Scheduler expands %NAME% in Command, Arguments and WorkingDirectory with no escape, so a value
16
16
  # holding one is refused before schtasks runs.
17
- # :reek:TooManyConstants { max_constants: 8 } -- the folder, the XML escapes and namespace, the os_id rule, quoting, globs, %NAME%, day names
17
+ # :reek:TooManyConstants { max_constants: 8 } -- the folder, the XML escapes and namespace, quoting, globs, %NAME%, day names, list rows
18
18
  class TaskScheduler
19
19
  FOLDER = '\\RubyRaider\\'
20
20
  ESCAPES = { '&' => '&amp;', '<' => '&lt;', '>' => '&gt;', '"' => '&quot;', "'" => '&apos;' }.freeze
21
21
  NAMESPACE = 'http://schemas.microsoft.com/windows/2004/02/mit/task'
22
- OS_ID = /\A#{Regexp.escape(Schedule::OS_ID_PREFIX)}\.[0-9a-f]{10}\.([a-z0-9_]{1,60})\z/
23
22
  # ruby.exe reads its own command line (w32_cmdvector in win32.c): ' quotes too, and a * ? [ { outside
24
23
  # '...' (even inside "...") makes it glob the argument
25
24
  NEEDS_QUOTES = /[ \t\n\v"']/
26
25
  GLOB = /[*?\[{]/
27
26
  EXPANDS = /%[^%]+%/
28
27
  DAY_NAMES = %w[Monday Tuesday Wednesday Thursday Friday Saturday Sunday].freeze
28
+ # A /Query /FO CSV row of a task right in our folder: its name, which our ids never quote or escape
29
+ ROW = /\A"#{Regexp.escape(FOLDER)}([^"\\]*)",/
29
30
 
30
31
  # One argument as ruby.exe reads it back (w32_cmdvector). With a glob character: '...', where nothing
31
32
  # is special and nothing globs (and with a ' too: refused). Otherwise "..." when empty or holding
@@ -44,14 +45,16 @@ module RubyRaider
44
45
  def self.glob_quote(arg)
45
46
  return "'#{arg}'" unless arg.include?("'")
46
47
 
47
- raise Error, "Task Scheduler can not pass #{Flows.printable(arg.inspect)} to ruby.exe: an argument with both ' and * ? [ or { would be globbed"
48
+ raise Error, "Task Scheduler can not pass #{Schedules.printable(arg.inspect)} to ruby.exe: an argument with both ' and * ? [ or { would be globbed"
48
49
  end
49
50
 
50
51
  def self.odd_doubled(slashes) = slashes.length.odd? ? "#{slashes * 2}\\" : slashes
51
52
 
52
53
  def self.join(args) = args.map { |arg| quote(arg) }.join(' ')
53
54
 
54
- def initialize(runner: CommandRunner.new, clock: Time, schtasks: File.join(ENV.fetch('SystemRoot', 'C:\\Windows'), 'System32', 'schtasks.exe'))
55
+ # kind: the Kind whose tasks this adapter registers and deletes
56
+ def initialize(kind:, runner: CommandRunner.new, clock: Time, schtasks: File.join(ENV.fetch('SystemRoot', 'C:\\Windows'), 'System32', 'schtasks.exe'))
57
+ @kind = kind
55
58
  @runner = runner
56
59
  @clock = clock
57
60
  @schtasks = schtasks
@@ -65,7 +68,7 @@ module RubyRaider
65
68
  def task_xml(schedule)
66
69
  refuse_expansion(schedule)
67
70
  body = [
68
- element('RegistrationInfo', [text('Description', "Ruby Raider flow #{schedule.name} (#{schedule.rule_text})"),
71
+ element('RegistrationInfo', [text('Description', @kind.task_description(schedule.name, schedule.rule_text)),
69
72
  text('URI', task_name(schedule.os_id))]),
70
73
  element('Triggers', element('CalendarTrigger', trigger(schedule))),
71
74
  element('Principals', element('Principal id="Author"', [text('LogonType', 'InteractiveToken'), text('RunLevel', 'LeastPrivilege')])),
@@ -79,13 +82,13 @@ module RubyRaider
79
82
  # Registers (or replaces) the task from a UTF-16LE temp file, deleted whatever happens
80
83
  def install(schedule)
81
84
  os_id = schedule.os_id
82
- file = Tempfile.new(['raider-flow-task', '.xml'], binmode: true)
85
+ file = Tempfile.new([@kind.task_file_prefix, '.xml'], binmode: true)
83
86
  file.write("\uFEFF#{task_xml(schedule)}".encode(Encoding::UTF_16LE))
84
87
  file.close
85
88
  result = schtasks('/Create', '/TN', task_name(os_id), '/XML', file.path.to_s, '/F')
86
89
  return true if result.success?
87
90
 
88
- raise Error, "Task Scheduler could not create #{os_id}: #{Flows.printable(result.stderr.strip)}"
91
+ raise Error, "Task Scheduler could not create #{os_id}: #{Schedules.printable(result.stderr.strip)}"
89
92
  ensure
90
93
  file&.close!
91
94
  end
@@ -99,17 +102,22 @@ module RubyRaider
99
102
  return true if result.success?
100
103
 
101
104
  listing = list
102
- return false if listing.success? && !listed?(listing, name)
105
+ return false if listing.success? && !listed?(listing.stdout, name)
103
106
 
104
- raise Error, "Task Scheduler could not delete #{os_id}: #{Flows.printable(result.stderr.strip)}"
107
+ raise Error, "Task Scheduler could not delete #{os_id}: #{Schedules.printable(result.stderr.strip)}"
105
108
  end
106
109
 
107
110
  def present?(os_id)
108
111
  name = task_name(os_id)
109
- listing = list
110
- return listed?(listing, name) if listing.success?
112
+ listed?(tasks, name)
113
+ end
111
114
 
112
- raise Error, "Task Scheduler could not list tasks: #{Flows.printable(listing.stderr.strip)}"
115
+ # The os_ids of this kind starting with prefix that are tasks in \\RubyRaider\\, sorted (one query)
116
+ def list_ids(prefix)
117
+ tasks.dup.force_encoding(Encoding::UTF_8).scrub.each_line.filter_map do |row|
118
+ id = row[ROW, 1]
119
+ id if @kind.listed?(id, prefix)
120
+ end.uniq.sort
113
121
  end
114
122
 
115
123
  private
@@ -119,21 +127,29 @@ module RubyRaider
119
127
  # Every task, one CSV row each, the full task name in the first field
120
128
  def list = schtasks('/Query', '/FO', 'CSV', '/NH')
121
129
 
122
- def listed?(listing, name) = listing.stdout.each_line.any? { |line| line.start_with?(%("#{name}",)) }
130
+ # The task list's text; an Error when it can't be read
131
+ def tasks
132
+ listing = list
133
+ return listing.stdout if listing.success?
134
+
135
+ raise Error, "Task Scheduler could not list tasks: #{Schedules.printable(listing.stderr.strip)}"
136
+ end
137
+
138
+ def listed?(text, name) = text.each_line.any? { |line| line.start_with?(%("#{name}",)) }
123
139
 
124
140
  def refuse_expansion(schedule)
125
141
  # The joined Arguments, as Task Scheduler expands them: a %...% can span two arguments
126
142
  value = [schedule.ruby, self.class.join(schedule.argv.drop(1)), schedule.working_directory].find { |item| item.match?(EXPANDS) }
127
143
  return unless value
128
144
 
129
- raise Error, "Task Scheduler would expand #{Flows.printable(value[EXPANDS])} as an environment variable in #{Flows.printable(value.inspect)}: " \
145
+ raise Error, "Task Scheduler would expand #{Schedules.printable(value[EXPANDS])} as an environment variable in #{Schedules.printable(value.inspect)}: " \
130
146
  'remove the %...% from it'
131
147
  end
132
148
 
133
149
  def checked_os_id(os_id)
134
- return os_id if os_id.is_a?(String) && (match = OS_ID.match(os_id)) && Flows.valid_name?(match[1])
150
+ return os_id if @kind.name_in(os_id)
135
151
 
136
- raise Error, "Invalid os_id #{Flows.printable(os_id.inspect)}: not a flow schedule"
152
+ raise Error, "Invalid os_id #{Schedules.printable(os_id.inspect)}: #{@kind.not_ours}"
137
153
  end
138
154
 
139
155
  def escape(value) = value.to_s.gsub(/[&<>"']/, ESCAPES)
@@ -160,7 +176,7 @@ module RubyRaider
160
176
  def calendar(schedule, every)
161
177
  return element('ScheduleByDay', text('DaysInterval', '1')) if %w[hour day].include?(every)
162
178
 
163
- days = schedule.days.map { |day| ["<#{DAY_NAMES.fetch(Schedule::DAYS.index(day).to_i)} />"] }
179
+ days = schedule.days.map { |day| ["<#{DAY_NAMES.fetch(DAYS.index(day).to_i)} />"] }
164
180
  element('ScheduleByWeek', [element('DaysOfWeek', days), text('WeeksInterval', '1')])
165
181
  end
166
182
 
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module RubyRaider
6
+ module Schedules
7
+ # The schedules of one project and kind that the OS scheduler has, by enumerating it for the project's
8
+ # os_id prefix ("<kind prefix>.<hash10>."). Every id is checked again here, so another project's, another
9
+ # kind's or a malformed id never becomes a name. An OS scheduler that can't be read gives none.
10
+ class OsEnumerator
11
+ # schedule_class: a Rule subclass (its Kind and os_id_prefix_for); root: the project's real path
12
+ def initialize(adapter, schedule_class:, root:)
13
+ @adapter = adapter
14
+ @kind = schedule_class.kind
15
+ @prefix = schedule_class.os_id_prefix_for(root)
16
+ end
17
+
18
+ # The names, sorted and once each
19
+ def names
20
+ adapter = @adapter
21
+ return [] unless adapter.respond_to?(:list_ids)
22
+
23
+ prefix = @prefix
24
+ Array(adapter.list_ids(prefix)).filter_map { |os_id| @kind.name_in(os_id) if @kind.listed?(os_id, prefix) }.uniq.sort
25
+ rescue Error
26
+ []
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module RubyRaider
6
+ module Schedules
7
+ # A project folder, by its real path: where its schedules and their files live
8
+ # :reek:MissingSafeMethod { exclude: [ check_show_browser!, check_raider_version! ] } -- checks that raise; runtime_driver? and locked_raider_version are their safe twins
9
+ # :reek:TooManyConstants { max_constants: 8 } -- the driver helper and Gemfile.lock checks
10
+ class Project
11
+ DRIVER_HELPER = 'helpers/driver_helper.rb'
12
+ # This gem's version, read as Raider#current_version reads it
13
+ OWN_VERSION = File.read(File.expand_path('../../version', __dir__)).strip.freeze
14
+ # The ruby_raider spec line of a GEM, PATH or GIT section (LF or CRLF); never evaluated
15
+ LOCKED_RAIDER = /^ {4}ruby_raider \(([0-9]+(?:\.[0-9A-Za-z]+)*)\)\r?$/
16
+ MAX_LOCK_BYTES = 5_000_000
17
+ # Every 4.x Selenium driver_helper loads the runtime, which honours RAIDER_SHOW_BROWSER
18
+ REQUIRES_RUNTIME = %r{^[ \t]*require[ \t]+(['"])ruby_raider/runtime\1}n
19
+ MAX_HELPER_BYTES = 1_000_000
20
+ NO_RUNTIME = 'Show the browser needs a project made with Ruby Raider 4.0 or later'
21
+
22
+ attr_reader :root
23
+
24
+ def initialize(path)
25
+ @root = File.realpath(path)
26
+ rescue SystemCallError
27
+ raise Error, "Project folder not found: #{path}"
28
+ end
29
+
30
+ def path(relative) = File.join(root, relative)
31
+
32
+ # `add --show-browser` refuses a project whose driver_helper can't drop --headless
33
+ def check_show_browser!
34
+ raise Error, NO_RUNTIME unless runtime_driver?
35
+ end
36
+
37
+ # The OS entry runs `bundle exec raider schedule run`, so the project's own bundle must have this command
38
+ def check_raider_version!(own = OWN_VERSION)
39
+ needs = "Scheduling needs ruby_raider #{own} or later in this project"
40
+ raise Error, "#{needs}: no Gemfile found" unless File.file?(path('Gemfile'))
41
+
42
+ locked = locked_raider_version
43
+ raise Error, "#{needs}: run bundle install first" if locked == :no_lock
44
+ return if locked && Gem::Version.new(locked) >= Gem::Version.new(own)
45
+
46
+ raise Error, "#{needs}: bundle update ruby_raider"
47
+ end
48
+
49
+ # The ruby_raider version Gemfile.lock resolves (any source), nil when it lists none, :no_lock without a lock
50
+ def locked_raider_version
51
+ lock = path('Gemfile.lock')
52
+ return :no_lock unless File.file?(lock)
53
+
54
+ File.binread(lock, MAX_LOCK_BYTES).to_s[LOCKED_RAIDER, 1]
55
+ rescue SystemCallError
56
+ :no_lock
57
+ end
58
+
59
+ # true when helpers/driver_helper.rb is a regular file inside the project that requires the runtime
60
+ def runtime_driver?
61
+ real = File.realpath(path(DRIVER_HELPER))
62
+ return false unless real.start_with?("#{root}#{File::SEPARATOR}") && File.file?(real)
63
+
64
+ REQUIRES_RUNTIME.match?(File.binread(real, MAX_HELPER_BYTES).to_s)
65
+ rescue SystemCallError
66
+ false
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'time'
5
+ require_relative 'kind'
6
+
7
+ module RubyRaider
8
+ module Schedules
9
+ # What every schedule shares, tests or 4.1: the rule (every/at/on), retries and notify, and what the OS
10
+ # entry runs (absolute ruby and bundle, GEM_HOME/GEM_PATH captured when it was scheduled). A subclass
11
+ # names its Kind (self.kind), checks its own name and fields in its initialize, and freezes.
12
+ # :reek:TooManyConstants { max_constants: 7 } -- the rule vocabulary and the OS entry parts
13
+ class Rule
14
+ EVERY = %w[hour day weekday week].freeze
15
+ DAYS = Schedules::DAYS
16
+ DAYS_RULE = 'use mon, tue, wed, thu, fri, sat or sun'
17
+ CLOCK = /\A([01]\d|2[0-3]):([0-5]\d)\z/
18
+ MINUTE = /\A(?:[01]\d|2[0-3])?:([0-5]\d)\z/
19
+ GEM_ENV = %w[GEM_HOME GEM_PATH].freeze
20
+ SYSTEM_PATH = %w[/usr/bin /bin].freeze
21
+
22
+ attr_reader :name, :every, :at, :on, :retries, :notify, :os_id, :ruby, :bundle, :gem_env, :project_root, :created_at
23
+
24
+ # The Kind of the subclass's schedules
25
+ def self.kind = raise(NotImplementedError, "#{self} must name its Kind")
26
+
27
+ def self.os_id_for(project_root, name) = "#{os_id_prefix_for(project_root)}#{name}"
28
+
29
+ # What every os_id of this kind for the project starts with: "<kind prefix>.<hash10>."
30
+ def self.os_id_prefix_for(project_root) = "#{kind.os_id_prefix}.#{Digest::SHA256.hexdigest(project_root)[0, 10]}."
31
+
32
+ # The absolute bundle of this Ruby. Gem.bin_path can also answer nil (seen on CI with a bare env).
33
+ def self.bundle_path
34
+ path = Gem.bin_path('bundler', 'bundle')
35
+ return path if path.is_a?(String) && File.absolute_path?(path)
36
+
37
+ raise Error, "Bundler not found for this Ruby (no bundle executable, got #{path.inspect}): gem install bundler, then try again"
38
+ rescue Gem::GemNotFoundException, Gem::MissingSpecError => e
39
+ raise Error, "Bundler not found for this Ruby (#{e.message}): gem install bundler, then try again"
40
+ end
41
+
42
+ # The GEM_HOME and GEM_PATH raider started with (before bundle exec changed them), only when set
43
+ def self.captured_gem_env
44
+ env = defined?(Bundler) ? Bundler.original_env : ENV
45
+ GEM_ENV.each_with_object({}) do |key, captured|
46
+ value = env[key]
47
+ captured[key] = value if value.is_a?(String) && !value.empty?
48
+ end
49
+ end
50
+
51
+ def kind = self.class.kind
52
+
53
+ def hour = at[0, 2].to_i
54
+
55
+ def minute = at[3, 2].to_i
56
+
57
+ def days
58
+ case every
59
+ when 'weekday' then DAYS.first(5)
60
+ when 'week' then [on.to_s]
61
+ else DAYS
62
+ end
63
+ end
64
+
65
+ # `raider <command> run NAME`, as the OS entry runs it
66
+ def argv = [ruby, bundle, 'exec', 'raider', kind.command, 'run', name, '--project', project_root, '--trigger', 'schedule']
67
+
68
+ def env
69
+ path = [File.dirname(ruby), File.dirname(bundle), *SYSTEM_PATH].uniq.join(File::PATH_SEPARATOR)
70
+ { 'PATH' => path, 'LANG' => 'en_US.UTF-8' }.merge(gem_env)
71
+ end
72
+
73
+ def working_directory = project_root
74
+
75
+ def log_path = File.join(project_root, kind.dir, name, 'scheduler.log')
76
+
77
+ def rule_text
78
+ case every
79
+ when 'hour' then "every hour at #{at[2, 3]}"
80
+ when 'week' then "every week on #{on} at #{at}"
81
+ else "every #{every} at #{at}"
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def checked_every(every)
88
+ return every.dup.freeze if EVERY.include?(every)
89
+
90
+ raise Error, "Invalid every #{every.inspect}: use hour, day, weekday or week"
91
+ end
92
+
93
+ # HH:MM, or for every hour :MM or HH:MM (the hour ignored, :00 when left out), kept as HH:MM
94
+ def checked_at(at)
95
+ return clock(at) unless every == 'hour'
96
+ return '00:00' if at.nil?
97
+
98
+ match = MINUTE.match(at.to_s)
99
+ raise Error, "Invalid at #{at.inspect}: use :MM or HH:MM for every hour" unless match
100
+
101
+ "00:#{match[1]}"
102
+ end
103
+
104
+ def clock(at)
105
+ raise Error, "Every #{every} needs at: use HH:MM (24h)" if at.nil?
106
+ return at.dup.freeze if at.is_a?(String) && CLOCK.match?(at)
107
+
108
+ raise Error, "Invalid at #{at.inspect}: use HH:MM (24h)"
109
+ end
110
+
111
+ def checked_on(on)
112
+ unless every == 'week'
113
+ raise Error, 'on is only for every week' unless on.nil?
114
+
115
+ return nil
116
+ end
117
+ raise Error, "Every week needs on: #{DAYS_RULE}" if on.nil?
118
+ return on.dup.freeze if DAYS.include?(on)
119
+
120
+ raise Error, "Invalid on #{on.inspect}: #{DAYS_RULE}"
121
+ end
122
+
123
+ def checked_retries(retries)
124
+ return retries if retries.is_a?(Integer) && retries.between?(0, 3)
125
+
126
+ raise Error, "Invalid retries #{retries.inspect}: use 0 to 3"
127
+ end
128
+
129
+ # An entry written before the flag existed has no key: off
130
+ def checked_flag(label, flag)
131
+ return flag == true if [true, false, nil].include?(flag)
132
+
133
+ raise Error, "Invalid #{label} #{flag.inspect}: use true or false"
134
+ end
135
+
136
+ def checked_os_id(os_id)
137
+ pattern = /\A#{Regexp.escape(kind.os_id_prefix)}\.[0-9a-f]{10}\.#{Regexp.escape(name)}\z/
138
+ return os_id.dup.freeze if os_id.is_a?(String) && pattern.match?(os_id)
139
+
140
+ raise Error, "Invalid os_id #{os_id.inspect} for #{name}"
141
+ end
142
+
143
+ def checked_path(label, path)
144
+ return checked_text(label, path) if path.is_a?(String) && File.absolute_path?(path)
145
+
146
+ raise Error, "Invalid #{label} #{path.inspect}: use an absolute path"
147
+ end
148
+
149
+ def checked_gem_env(gem_env)
150
+ raise Error, 'Invalid gem_env: use a mapping of GEM_HOME and GEM_PATH' unless gem_env.is_a?(Hash) && (gem_env.keys - GEM_ENV).empty?
151
+
152
+ gem_env.to_h { |key, value| [key.dup.freeze, checked_text(key, value)] }.freeze
153
+ end
154
+
155
+ def checked_text(label, text)
156
+ return text.dup.freeze if text.is_a?(String) && text.valid_encoding? && !text.empty? && !text.match?(/[[:cntrl:]]/)
157
+
158
+ raise Error, "Invalid #{label} #{text.inspect}"
159
+ end
160
+ end
161
+ end
162
+ end