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.
- checksums.yaml +4 -4
- data/.github/workflows/e2e_tests.yml +3 -0
- data/README.md +52 -53
- data/RELEASE.md +14 -1
- data/lib/commands/schedule_commands.rb +84 -0
- data/lib/generators/templates/common/git_ignore.tt +1 -0
- data/lib/ruby_raider/runtime/retry_reset.rb +32 -0
- data/lib/ruby_raider/runtime/rspec.rb +1 -0
- data/lib/ruby_raider/runtime/show_browser.rb +53 -0
- data/lib/ruby_raider/runtime.rb +2 -0
- data/lib/ruby_raider.rb +22 -7
- data/lib/utilities/schedules/adapter.rb +22 -0
- data/lib/utilities/schedules/base.rb +27 -0
- data/lib/utilities/schedules/kind.rb +74 -0
- data/lib/utilities/schedules/legacy.rb +196 -0
- data/lib/utilities/schedules/legacy_stub.rb +62 -0
- data/lib/utilities/{flows → schedules}/next_run.rb +2 -2
- data/lib/utilities/{flows → schedules}/notifier.rb +6 -6
- data/lib/utilities/{flows → schedules}/os/command_runner.rb +1 -1
- data/lib/utilities/{flows → schedules}/os/cron.rb +27 -17
- data/lib/utilities/{flows → schedules}/os/launchd.rb +32 -14
- data/lib/utilities/{flows → schedules}/os/linux.rb +23 -5
- data/lib/utilities/{flows → schedules}/os/systemd.rb +33 -17
- data/lib/utilities/{flows → schedules}/os/task_scheduler.rb +36 -20
- data/lib/utilities/schedules/os_enumerator.rb +30 -0
- data/lib/utilities/schedules/project.rb +70 -0
- data/lib/utilities/schedules/rule.rb +162 -0
- data/lib/utilities/schedules/run_lock.rb +160 -0
- data/lib/utilities/schedules/run_log.rb +122 -0
- data/lib/utilities/schedules/run_report.rb +139 -0
- data/lib/utilities/schedules/run_wrapper.rb +216 -0
- data/lib/utilities/schedules/schedule.rb +89 -0
- data/lib/utilities/schedules/schedule_store.rb +53 -0
- data/lib/utilities/schedules/scheduler.rb +178 -0
- data/lib/utilities/schedules/scheduler_steps.rb +69 -0
- data/lib/utilities/schedules/selection.rb +133 -0
- data/lib/utilities/schedules/spawn.rb +68 -0
- data/lib/utilities/{flows/schedule_store.rb → schedules/store.rb} +17 -13
- data/lib/version +1 -1
- data/ruby_raider.gemspec +1 -0
- data/sig/commands/schedule_commands.rbs +12 -0
- data/sig/ruby_raider/runtime/retry_reset.rbs +7 -0
- data/sig/ruby_raider/runtime/show_browser.rbs +27 -0
- data/sig/ruby_raider.rbs +4 -0
- data/sig/utilities/schedules/adapter.rbs +5 -0
- data/sig/utilities/schedules/base.rbs +15 -0
- data/sig/utilities/schedules/kind.rbs +32 -0
- data/sig/utilities/schedules/legacy.rbs +45 -0
- data/sig/utilities/schedules/legacy_stub.rbs +15 -0
- data/sig/utilities/{flows → schedules}/next_run.rbs +3 -3
- data/sig/utilities/{flows → schedules}/notifier.rbs +1 -1
- data/sig/utilities/{flows → schedules}/os/command_runner.rbs +1 -1
- data/sig/utilities/{flows → schedules}/os/cron.rbs +7 -7
- data/sig/utilities/{flows → schedules}/os/launchd.rbs +7 -6
- data/sig/utilities/{flows → schedules}/os/linux.rbs +10 -4
- data/sig/utilities/{flows → schedules}/os/systemd.rbs +8 -7
- data/sig/utilities/{flows → schedules}/os/task_scheduler.rbs +13 -10
- data/sig/utilities/schedules/os_enumerator.rbs +12 -0
- data/sig/utilities/schedules/project.rbs +22 -0
- data/sig/utilities/{flows/schedule.rbs → schedules/rule.rbs} +23 -12
- data/sig/utilities/schedules/run_lock.rbs +29 -0
- data/sig/utilities/schedules/run_log.rbs +33 -0
- data/sig/utilities/schedules/run_report.rbs +34 -0
- data/sig/utilities/schedules/run_wrapper.rbs +45 -0
- data/sig/utilities/schedules/schedule.rbs +27 -0
- data/sig/utilities/schedules/schedule_store.rbs +21 -0
- data/sig/utilities/{flows → schedules}/scheduler.rbs +22 -15
- data/sig/utilities/schedules/scheduler_steps.rbs +22 -0
- data/sig/utilities/schedules/selection.rbs +36 -0
- data/sig/utilities/schedules/spawn.rbs +10 -0
- data/sig/utilities/{flows/schedule_store.rbs → schedules/store.rbs} +14 -9
- data/spec/commands/schedule_commands_spec.rb +284 -0
- data/spec/integration/content/common_content_spec.rb +8 -2
- data/spec/integration/content/gemfile_content_spec.rb +2 -2
- data/spec/integration/end_to_end_spec.rb +0 -63
- data/spec/runtime/retry_reset_spec.rb +112 -0
- data/spec/runtime/show_browser_project_spec.rb +70 -0
- data/spec/runtime/show_browser_spec.rb +101 -0
- data/spec/utilities/schedules/kind_spec.rb +81 -0
- data/spec/utilities/schedules/legacy_spec.rb +433 -0
- data/spec/utilities/schedules/legacy_stub_spec.rb +144 -0
- data/spec/utilities/{flows → schedules}/next_run_spec.rb +5 -5
- data/spec/utilities/{flows → schedules}/notifier_spec.rb +3 -4
- data/spec/utilities/{flows → schedules}/os/command_runner_spec.rb +2 -3
- data/spec/utilities/{flows → schedules}/os/cron_spec.rb +34 -29
- data/spec/utilities/{flows → schedules}/os/launchd_spec.rb +42 -29
- data/spec/utilities/{flows → schedules}/os/linux_spec.rb +27 -13
- data/spec/utilities/schedules/os/list_ids_spec.rb +202 -0
- data/spec/utilities/{flows → schedules}/os/systemd_spec.rb +35 -31
- data/spec/utilities/{flows → schedules}/os/task_scheduler_spec.rb +40 -34
- data/spec/utilities/schedules/os_entry_spec.rb +54 -0
- data/spec/utilities/schedules/os_enumerator_spec.rb +57 -0
- data/spec/utilities/schedules/project_spec.rb +125 -0
- data/spec/utilities/schedules/run_lock_spec.rb +143 -0
- data/spec/utilities/schedules/run_log_spec.rb +83 -0
- data/spec/utilities/schedules/run_report_spec.rb +150 -0
- data/spec/utilities/schedules/run_wrapper_integration_spec.rb +166 -0
- data/spec/utilities/schedules/run_wrapper_spec.rb +430 -0
- data/spec/utilities/schedules/schedule_spec.rb +120 -0
- data/spec/utilities/schedules/schedule_store_spec.rb +151 -0
- data/spec/utilities/schedules/scheduler_launchd_spec.rb +77 -0
- data/spec/utilities/schedules/scheduler_spec.rb +445 -0
- data/spec/utilities/schedules/selection_spec.rb +176 -0
- data/spec/utilities/schedules/spawn_spec.rb +104 -0
- metadata +106 -53
- data/lib/commands/flow_commands.rb +0 -104
- data/lib/generators/templates/flows/flow_helper.tt +0 -42
- data/lib/generators/templates/flows/run.tt +0 -332
- data/lib/utilities/flows/project.rb +0 -170
- data/lib/utilities/flows/run_log.rb +0 -61
- data/lib/utilities/flows/runner.rb +0 -206
- data/lib/utilities/flows/schedule.rb +0 -221
- data/lib/utilities/flows/scheduler.rb +0 -187
- data/sig/commands/flow_commands.rbs +0 -13
- data/sig/utilities/flows/project.rbs +0 -46
- data/sig/utilities/flows/run_log.rbs +0 -22
- data/sig/utilities/flows/runner.rbs +0 -35
- data/spec/commands/flow_commands_spec.rb +0 -128
- data/spec/generators/flow_helper_spec.rb +0 -40
- data/spec/integration/flows/flow_project.rb +0 -62
- data/spec/integration/flows/run_command_spec.rb +0 -91
- data/spec/integration/flows/run_lifecycle_spec.rb +0 -383
- data/spec/utilities/flows/project_spec.rb +0 -178
- data/spec/utilities/flows/run_log_spec.rb +0 -47
- data/spec/utilities/flows/runner_spec.rb +0 -313
- data/spec/utilities/flows/schedule_spec.rb +0 -233
- data/spec/utilities/flows/schedule_store_spec.rb +0 -137
- 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 '../
|
|
6
|
+
require_relative '../base'
|
|
7
7
|
|
|
8
8
|
module RubyRaider
|
|
9
|
-
module
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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}: #{
|
|
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 { |
|
|
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}: #{
|
|
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? { |
|
|
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
|
|
130
|
+
return os_id if @kind.name_in(os_id)
|
|
115
131
|
|
|
116
|
-
raise Error, "Invalid os_id #{
|
|
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 #{
|
|
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 { |
|
|
158
|
+
%w[service timer].each { |unit| unlink(unit_path(os_id, unit)) }
|
|
143
159
|
reload = systemctl('daemon-reload')
|
|
144
|
-
reload.success? ? '' : "; also: reload failed: #{
|
|
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 '../
|
|
5
|
+
require_relative '../base'
|
|
6
6
|
require_relative '../next_run'
|
|
7
7
|
|
|
8
8
|
module RubyRaider
|
|
9
|
-
module
|
|
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
|
|
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,
|
|
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 = { '&' => '&', '<' => '<', '>' => '>', '"' => '"', "'" => ''' }.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 #{
|
|
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
|
-
|
|
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',
|
|
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([
|
|
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}: #{
|
|
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}: #{
|
|
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
|
-
|
|
110
|
-
|
|
112
|
+
listed?(tasks, name)
|
|
113
|
+
end
|
|
111
114
|
|
|
112
|
-
|
|
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
|
-
|
|
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 #{
|
|
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
|
|
150
|
+
return os_id if @kind.name_in(os_id)
|
|
135
151
|
|
|
136
|
-
raise Error, "Invalid os_id #{
|
|
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(
|
|
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
|