ruby_raider 4.0.1 → 4.1.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/README.md +59 -1
- data/RELEASE.md +12 -0
- data/lib/commands/flow_commands.rb +104 -0
- data/lib/generators/templates/flows/flow_helper.tt +42 -0
- data/lib/generators/templates/flows/run.tt +332 -0
- data/lib/ruby_raider.rb +16 -0
- data/lib/utilities/flows/next_run.rb +61 -0
- data/lib/utilities/flows/notifier.rb +76 -0
- data/lib/utilities/flows/os/command_runner.rb +73 -0
- data/lib/utilities/flows/os/cron.rb +137 -0
- data/lib/utilities/flows/os/launchd.rb +164 -0
- data/lib/utilities/flows/os/linux.rb +63 -0
- data/lib/utilities/flows/os/systemd.rb +178 -0
- data/lib/utilities/flows/os/task_scheduler.rb +179 -0
- data/lib/utilities/flows/project.rb +170 -0
- data/lib/utilities/flows/run_log.rb +61 -0
- data/lib/utilities/flows/runner.rb +206 -0
- data/lib/utilities/flows/schedule.rb +221 -0
- data/lib/utilities/flows/schedule_store.rb +135 -0
- data/lib/utilities/flows/scheduler.rb +187 -0
- data/lib/version +1 -1
- data/sig/commands/flow_commands.rbs +13 -0
- data/sig/ruby_raider.rbs +2 -0
- data/sig/utilities/flows/next_run.rbs +15 -0
- data/sig/utilities/flows/notifier.rbs +29 -0
- data/sig/utilities/flows/os/command_runner.rbs +27 -0
- data/sig/utilities/flows/os/cron.rbs +34 -0
- data/sig/utilities/flows/os/launchd.rbs +45 -0
- data/sig/utilities/flows/os/linux.rbs +27 -0
- data/sig/utilities/flows/os/systemd.rbs +43 -0
- data/sig/utilities/flows/os/task_scheduler.rbs +53 -0
- data/sig/utilities/flows/project.rbs +46 -0
- data/sig/utilities/flows/run_log.rbs +22 -0
- data/sig/utilities/flows/runner.rbs +35 -0
- data/sig/utilities/flows/schedule.rbs +61 -0
- data/sig/utilities/flows/schedule_store.rbs +34 -0
- data/sig/utilities/flows/scheduler.rbs +42 -0
- data/spec/commands/flow_commands_spec.rb +128 -0
- data/spec/generators/flow_helper_spec.rb +40 -0
- data/spec/integration/content/gemfile_content_spec.rb +2 -2
- data/spec/integration/end_to_end_spec.rb +63 -0
- data/spec/integration/flows/flow_project.rb +62 -0
- data/spec/integration/flows/run_command_spec.rb +91 -0
- data/spec/integration/flows/run_lifecycle_spec.rb +383 -0
- data/spec/utilities/flows/next_run_spec.rb +119 -0
- data/spec/utilities/flows/notifier_spec.rb +121 -0
- data/spec/utilities/flows/os/command_runner_spec.rb +56 -0
- data/spec/utilities/flows/os/cron_spec.rb +287 -0
- data/spec/utilities/flows/os/launchd_spec.rb +293 -0
- data/spec/utilities/flows/os/linux_spec.rb +149 -0
- data/spec/utilities/flows/os/systemd_spec.rb +329 -0
- data/spec/utilities/flows/os/task_scheduler_spec.rb +505 -0
- data/spec/utilities/flows/project_spec.rb +178 -0
- data/spec/utilities/flows/run_log_spec.rb +47 -0
- data/spec/utilities/flows/runner_spec.rb +313 -0
- data/spec/utilities/flows/schedule_spec.rb +233 -0
- data/spec/utilities/flows/schedule_store_spec.rb +137 -0
- data/spec/utilities/flows/scheduler_spec.rb +329 -0
- metadata +53 -2
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require_relative 'command_runner'
|
|
6
|
+
require_relative '../schedule'
|
|
7
|
+
|
|
8
|
+
module RubyRaider
|
|
9
|
+
module Flows
|
|
10
|
+
module Os
|
|
11
|
+
# Linux: a oneshot service and a persistent timer per schedule in the user's systemd folder, enabled
|
|
12
|
+
# with systemctl --user. Words in ExecStart= and Environment= are double-quoted with C escapes; every
|
|
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
|
|
15
|
+
class Systemd
|
|
16
|
+
SYSTEMCTL = 'systemctl'
|
|
17
|
+
NOFOLLOW = defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0
|
|
18
|
+
QUOTED = { '\\' => '\\\\', '"' => '\\"', '%' => '%%' }.freeze
|
|
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
|
+
# The unit parser strips whitespace at the ends of a value and reads a trailing backslash as "continued"
|
|
22
|
+
UNSAFE_PATH = /\s\z|\\\z/
|
|
23
|
+
|
|
24
|
+
def initialize(runner: CommandRunner.new, home: Dir.home, config_home: ENV.fetch('XDG_CONFIG_HOME', nil))
|
|
25
|
+
@runner = runner
|
|
26
|
+
@home = home
|
|
27
|
+
@config_home = config_home
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def label = 'systemd'
|
|
31
|
+
|
|
32
|
+
def missed_runs_note = nil
|
|
33
|
+
|
|
34
|
+
def service(schedule)
|
|
35
|
+
env = schedule.env.map { |key, value| "Environment=#{quote("#{key}=#{value}")}" }
|
|
36
|
+
exec = schedule.argv.map { |arg| quote(arg.gsub('$', '$$')) }.join(' ')
|
|
37
|
+
log = specifiers(schedule.log_path)
|
|
38
|
+
<<~UNIT
|
|
39
|
+
[Unit]
|
|
40
|
+
Description=raider flow #{schedule.name}
|
|
41
|
+
|
|
42
|
+
[Service]
|
|
43
|
+
Type=oneshot
|
|
44
|
+
WorkingDirectory=#{specifiers(schedule.working_directory)}
|
|
45
|
+
#{env.join("\n")}
|
|
46
|
+
ExecStart=#{exec}
|
|
47
|
+
StandardOutput=append:#{log}
|
|
48
|
+
StandardError=append:#{log}
|
|
49
|
+
UNIT
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Local time; Persistent= runs a missed time once the user manager is back
|
|
53
|
+
def timer(schedule)
|
|
54
|
+
every = schedule.every
|
|
55
|
+
day = CALENDAR.fetch(every) { "#{schedule.on.to_s.capitalize} " }
|
|
56
|
+
hour = every == 'hour' ? '*' : format('%02d', schedule.hour)
|
|
57
|
+
<<~UNIT
|
|
58
|
+
[Unit]
|
|
59
|
+
Description=raider flow #{schedule.name} schedule
|
|
60
|
+
|
|
61
|
+
[Timer]
|
|
62
|
+
OnCalendar=#{day}*-*-* #{hour}:#{format('%02d', schedule.minute)}:00
|
|
63
|
+
Persistent=true
|
|
64
|
+
|
|
65
|
+
[Install]
|
|
66
|
+
WantedBy=timers.target
|
|
67
|
+
UNIT
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def unit_path(os_id, kind) = File.join(units_dir, "#{checked_os_id(os_id)}.#{kind}")
|
|
71
|
+
|
|
72
|
+
# Writes both units (temp files renamed into place), reloads and enables the timer; a failure leaves no unit behind
|
|
73
|
+
def install(schedule)
|
|
74
|
+
os_id = schedule.os_id
|
|
75
|
+
[schedule.working_directory, schedule.log_path].each { |path| checked_path(path) }
|
|
76
|
+
write({ unit_path(os_id, 'service') => service(schedule), unit_path(os_id, 'timer') => timer(schedule) })
|
|
77
|
+
failure = [systemctl('daemon-reload'), systemctl('enable', '--now', "#{os_id}.timer")].find { |result| !result.success? }
|
|
78
|
+
return true unless failure
|
|
79
|
+
|
|
80
|
+
message = "systemd could not enable #{os_id}: #{Flows.printable(failure.stderr.strip)}"
|
|
81
|
+
raise Error, "#{message}#{discard(os_id)}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# True when a unit file was removed, false when it was already gone
|
|
85
|
+
def remove(os_id)
|
|
86
|
+
paths = %w[service timer].map { |kind| unit_path(os_id, kind) }
|
|
87
|
+
systemctl('disable', '--now', "#{os_id}.timer")
|
|
88
|
+
removed = paths.map { |path| unlink(path) }.any?
|
|
89
|
+
reload = systemctl('daemon-reload')
|
|
90
|
+
raise Error, "systemd could not reload after removing #{os_id}: #{Flows.printable(reload.stderr.strip)}" unless reload.success?
|
|
91
|
+
|
|
92
|
+
removed
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def present?(os_id) = systemctl('is-enabled', "#{checked_os_id(os_id)}.timer").success?
|
|
96
|
+
|
|
97
|
+
# 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)) }
|
|
99
|
+
|
|
100
|
+
# True when the user's systemd manager answers
|
|
101
|
+
def usable? = systemctl('show-environment').success?
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def systemctl(*args) = @runner.call(SYSTEMCTL, '--user', *args)
|
|
106
|
+
|
|
107
|
+
def units_dir
|
|
108
|
+
config = @config_home
|
|
109
|
+
base = config.is_a?(String) && File.absolute_path?(config) ? config : File.join(@home, '.config')
|
|
110
|
+
File.join(base, 'systemd', 'user')
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
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])
|
|
115
|
+
|
|
116
|
+
raise Error, "Invalid os_id #{Flows.printable(os_id.inspect)}: not a flow schedule"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
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)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A word systemd gives back exactly: double quotes, \\ and \" escapes, %% for %
|
|
124
|
+
def quote(text) = %("#{text.gsub(/[\\"%]/, QUOTED)}")
|
|
125
|
+
|
|
126
|
+
# A raw path value: only specifiers apply
|
|
127
|
+
def specifiers(text) = text.gsub('%', '%%')
|
|
128
|
+
|
|
129
|
+
# True when the file was there; a unit that stays behind is an error, not "already gone"
|
|
130
|
+
def unlink(path)
|
|
131
|
+
File.unlink(path)
|
|
132
|
+
true
|
|
133
|
+
rescue Errno::ENOENT
|
|
134
|
+
false
|
|
135
|
+
rescue SystemCallError => e
|
|
136
|
+
raise Error, "Could not remove #{path}: #{e.message}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Disables the timer, removes both units and reloads; returns what to add to the error when that fails too
|
|
140
|
+
def discard(os_id)
|
|
141
|
+
systemctl('disable', "#{os_id}.timer")
|
|
142
|
+
%w[service timer].each { |kind| unlink(unit_path(os_id, kind)) }
|
|
143
|
+
reload = systemctl('daemon-reload')
|
|
144
|
+
reload.success? ? '' : "; also: reload failed: #{Flows.printable(reload.stderr.strip)}"
|
|
145
|
+
rescue Error => e
|
|
146
|
+
"; also: #{e.message}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Every temp file is written before any unit is replaced, so a failed write changes nothing
|
|
150
|
+
def write(texts)
|
|
151
|
+
temps = {}
|
|
152
|
+
texts.each { |path, text| temps[path] = temp_file(path, text) }
|
|
153
|
+
temps.each { |path, temp| File.rename(temp, path) }
|
|
154
|
+
rescue SystemCallError => e
|
|
155
|
+
raise Error, "Could not write the systemd units in #{units_dir}: #{e.message}"
|
|
156
|
+
ensure
|
|
157
|
+
temps&.each_value { |temp| FileUtils.rm_f(temp) if File.exist?(temp) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def temp_file(path, text)
|
|
161
|
+
dir = File.dirname(path)
|
|
162
|
+
FileUtils.mkdir_p(dir)
|
|
163
|
+
temp = File.join(dir, ".#{File.basename(path)}.#{SecureRandom.hex(6)}.tmp")
|
|
164
|
+
File.open(temp, File::WRONLY | File::CREAT | File::EXCL | NOFOLLOW, 0o644) do |file|
|
|
165
|
+
file.write(text)
|
|
166
|
+
file.flush
|
|
167
|
+
file.fsync
|
|
168
|
+
file.chmod(0o644)
|
|
169
|
+
end
|
|
170
|
+
temp
|
|
171
|
+
rescue SystemCallError
|
|
172
|
+
FileUtils.rm_f(temp) if temp && File.file?(temp)
|
|
173
|
+
raise
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
require_relative 'command_runner'
|
|
5
|
+
require_relative '../schedule'
|
|
6
|
+
require_relative '../next_run'
|
|
7
|
+
|
|
8
|
+
module RubyRaider
|
|
9
|
+
module Flows
|
|
10
|
+
module Os
|
|
11
|
+
# Windows: one task per schedule in the \RubyRaider\ folder, registered from task XML with schtasks.
|
|
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
|
|
14
|
+
# quoting, then every value in the XML goes through one XML escaper.
|
|
15
|
+
# Task Scheduler expands %NAME% in Command, Arguments and WorkingDirectory with no escape, so a value
|
|
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
|
|
18
|
+
class TaskScheduler
|
|
19
|
+
FOLDER = '\\RubyRaider\\'
|
|
20
|
+
ESCAPES = { '&' => '&', '<' => '<', '>' => '>', '"' => '"', "'" => ''' }.freeze
|
|
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
|
+
# ruby.exe reads its own command line (w32_cmdvector in win32.c): ' quotes too, and a * ? [ { outside
|
|
24
|
+
# '...' (even inside "...") makes it glob the argument
|
|
25
|
+
NEEDS_QUOTES = /[ \t\n\v"']/
|
|
26
|
+
GLOB = /[*?\[{]/
|
|
27
|
+
EXPANDS = /%[^%]+%/
|
|
28
|
+
DAY_NAMES = %w[Monday Tuesday Wednesday Thursday Friday Saturday Sunday].freeze
|
|
29
|
+
|
|
30
|
+
# One argument as ruby.exe reads it back (w32_cmdvector). With a glob character: '...', where nothing
|
|
31
|
+
# is special and nothing globs (and with a ' too: refused). Otherwise "..." when empty or holding
|
|
32
|
+
# whitespace or a quote: backslashes before " (or the closing ") are doubled plus one escapes the ",
|
|
33
|
+
# and an odd run before ' becomes 2n+1 (ruby.exe eats (n+1)/2 of them; an even run is kept as is).
|
|
34
|
+
def self.quote(arg)
|
|
35
|
+
return glob_quote(arg) if arg.match?(GLOB)
|
|
36
|
+
return arg if !arg.empty? && !arg.match?(NEEDS_QUOTES)
|
|
37
|
+
|
|
38
|
+
body = arg.gsub(/(\\*)"/) { "#{Regexp.last_match(1).to_s * 2}\\\"" }
|
|
39
|
+
.gsub(/(\\+)'/) { "#{odd_doubled(Regexp.last_match(1).to_s)}'" }
|
|
40
|
+
.sub(/(\\+)\z/) { Regexp.last_match(1).to_s * 2 }
|
|
41
|
+
"\"#{body}\""
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.glob_quote(arg)
|
|
45
|
+
return "'#{arg}'" unless arg.include?("'")
|
|
46
|
+
|
|
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
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.odd_doubled(slashes) = slashes.length.odd? ? "#{slashes * 2}\\" : slashes
|
|
51
|
+
|
|
52
|
+
def self.join(args) = args.map { |arg| quote(arg) }.join(' ')
|
|
53
|
+
|
|
54
|
+
def initialize(runner: CommandRunner.new, clock: Time, schtasks: File.join(ENV.fetch('SystemRoot', 'C:\\Windows'), 'System32', 'schtasks.exe'))
|
|
55
|
+
@runner = runner
|
|
56
|
+
@clock = clock
|
|
57
|
+
@schtasks = schtasks
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def label = 'Task Scheduler'
|
|
61
|
+
|
|
62
|
+
def task_name(os_id) = "#{FOLDER}#{checked_os_id(os_id)}"
|
|
63
|
+
|
|
64
|
+
# The task XML (a UTF-8 String; install writes it as UTF-16LE with a BOM)
|
|
65
|
+
def task_xml(schedule)
|
|
66
|
+
refuse_expansion(schedule)
|
|
67
|
+
body = [
|
|
68
|
+
element('RegistrationInfo', [text('Description', "Ruby Raider flow #{schedule.name} (#{schedule.rule_text})"),
|
|
69
|
+
text('URI', task_name(schedule.os_id))]),
|
|
70
|
+
element('Triggers', element('CalendarTrigger', trigger(schedule))),
|
|
71
|
+
element('Principals', element('Principal id="Author"', [text('LogonType', 'InteractiveToken'), text('RunLevel', 'LeastPrivilege')])),
|
|
72
|
+
element('Settings', settings),
|
|
73
|
+
element('Actions Context="Author"', element('Exec', action(schedule)))
|
|
74
|
+
]
|
|
75
|
+
lines = ['<?xml version="1.0" encoding="UTF-16"?>', %(<Task version="1.2" xmlns="#{NAMESPACE}">), *indent(body.flatten), '</Task>']
|
|
76
|
+
"#{lines.join("\n")}\n"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Registers (or replaces) the task from a UTF-16LE temp file, deleted whatever happens
|
|
80
|
+
def install(schedule)
|
|
81
|
+
os_id = schedule.os_id
|
|
82
|
+
file = Tempfile.new(['raider-flow-task', '.xml'], binmode: true)
|
|
83
|
+
file.write("\uFEFF#{task_xml(schedule)}".encode(Encoding::UTF_16LE))
|
|
84
|
+
file.close
|
|
85
|
+
result = schtasks('/Create', '/TN', task_name(os_id), '/XML', file.path.to_s, '/F')
|
|
86
|
+
return true if result.success?
|
|
87
|
+
|
|
88
|
+
raise Error, "Task Scheduler could not create #{os_id}: #{Flows.printable(result.stderr.strip)}"
|
|
89
|
+
ensure
|
|
90
|
+
file&.close!
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# True when the task was deleted, false when it was already gone. schtasks words "not found" in
|
|
94
|
+
# the system language, so a failed delete is checked against the task list; a list that can not
|
|
95
|
+
# be read, or still shows the task, is an error.
|
|
96
|
+
def remove(os_id)
|
|
97
|
+
name = task_name(os_id)
|
|
98
|
+
result = schtasks('/Delete', '/TN', name, '/F')
|
|
99
|
+
return true if result.success?
|
|
100
|
+
|
|
101
|
+
listing = list
|
|
102
|
+
return false if listing.success? && !listed?(listing, name)
|
|
103
|
+
|
|
104
|
+
raise Error, "Task Scheduler could not delete #{os_id}: #{Flows.printable(result.stderr.strip)}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def present?(os_id)
|
|
108
|
+
name = task_name(os_id)
|
|
109
|
+
listing = list
|
|
110
|
+
return listed?(listing, name) if listing.success?
|
|
111
|
+
|
|
112
|
+
raise Error, "Task Scheduler could not list tasks: #{Flows.printable(listing.stderr.strip)}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def schtasks(*args) = @runner.call(@schtasks, *args)
|
|
118
|
+
|
|
119
|
+
# Every task, one CSV row each, the full task name in the first field
|
|
120
|
+
def list = schtasks('/Query', '/FO', 'CSV', '/NH')
|
|
121
|
+
|
|
122
|
+
def listed?(listing, name) = listing.stdout.each_line.any? { |line| line.start_with?(%("#{name}",)) }
|
|
123
|
+
|
|
124
|
+
def refuse_expansion(schedule)
|
|
125
|
+
# The joined Arguments, as Task Scheduler expands them: a %...% can span two arguments
|
|
126
|
+
value = [schedule.ruby, self.class.join(schedule.argv.drop(1)), schedule.working_directory].find { |item| item.match?(EXPANDS) }
|
|
127
|
+
return unless value
|
|
128
|
+
|
|
129
|
+
raise Error, "Task Scheduler would expand #{Flows.printable(value[EXPANDS])} as an environment variable in #{Flows.printable(value.inspect)}: " \
|
|
130
|
+
'remove the %...% from it'
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
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])
|
|
135
|
+
|
|
136
|
+
raise Error, "Invalid os_id #{Flows.printable(os_id.inspect)}: not a flow schedule"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def escape(value) = value.to_s.gsub(/[&<>"']/, ESCAPES)
|
|
140
|
+
|
|
141
|
+
def text(name, value) = ["<#{name}>#{escape(value)}</#{name}>"]
|
|
142
|
+
|
|
143
|
+
# tag may carry fixed attributes ('Principal id="Author"'); the closing tag is its first word
|
|
144
|
+
def element(tag, children) = ["<#{tag}>", *indent(children.flatten), "</#{tag.split.first}>"]
|
|
145
|
+
|
|
146
|
+
def indent(lines) = lines.map { |line| " #{line}" }
|
|
147
|
+
|
|
148
|
+
# Local time without an offset, never in the past (StartWhenAvailable would run a missed start at
|
|
149
|
+
# once): the next run's date, at the rule's own HH:MM so a DST-shifted next run does not move the
|
|
150
|
+
# daily time; an hour rule starts at its next run.
|
|
151
|
+
def trigger(schedule)
|
|
152
|
+
every = schedule.every
|
|
153
|
+
next_run = NextRun.after(schedule, @clock.now)
|
|
154
|
+
hour = every == 'hour' ? next_run.hour : schedule.hour
|
|
155
|
+
start = format('%<date>sT%<hour>02d:%<minute>02d:00', date: next_run.strftime('%Y-%m-%d'), hour: hour, minute: schedule.minute)
|
|
156
|
+
repeat = every == 'hour' ? [element('Repetition', [text('Interval', 'PT1H'), text('Duration', 'P1D')])] : []
|
|
157
|
+
[*repeat, text('StartBoundary', start), text('Enabled', 'true'), *calendar(schedule, every)]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def calendar(schedule, every)
|
|
161
|
+
return element('ScheduleByDay', text('DaysInterval', '1')) if %w[hour day].include?(every)
|
|
162
|
+
|
|
163
|
+
days = schedule.days.map { |day| ["<#{DAY_NAMES.fetch(Schedule::DAYS.index(day).to_i)} />"] }
|
|
164
|
+
element('ScheduleByWeek', [element('DaysOfWeek', days), text('WeeksInterval', '1')])
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def settings
|
|
168
|
+
[text('MultipleInstancesPolicy', 'IgnoreNew'), text('DisallowStartIfOnBatteries', 'false'),
|
|
169
|
+
text('StopIfGoingOnBatteries', 'false'), text('StartWhenAvailable', 'true'), text('Enabled', 'true')]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def action(schedule)
|
|
173
|
+
ruby, *args = schedule.argv
|
|
174
|
+
[text('Command', ruby), text('Arguments', self.class.join(args)), text('WorkingDirectory', schedule.working_directory)]
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module RubyRaider
|
|
7
|
+
module Flows
|
|
8
|
+
NAME = /\A[a-z0-9_]{1,60}\z/
|
|
9
|
+
# .raider/flows/env is the secrets file, so no flow may log to .raider/flows/env/
|
|
10
|
+
RESERVED = %w[env].freeze
|
|
11
|
+
|
|
12
|
+
def self.valid_name?(name) = NAME.match?(name.to_s) && !RESERVED.include?(name.to_s)
|
|
13
|
+
|
|
14
|
+
# Text from a run log, safe to print: page text in a step title can't move the cursor or forge lines
|
|
15
|
+
def self.printable(text) = text.to_s.gsub(/[[:cntrl:]]/, '?')
|
|
16
|
+
|
|
17
|
+
# A problem that stops a flow command; exit_code follows `raider flow run` (2 = setup failed).
|
|
18
|
+
class Error < StandardError
|
|
19
|
+
attr_reader :exit_code
|
|
20
|
+
|
|
21
|
+
def initialize(message, exit_code: 2)
|
|
22
|
+
super(message)
|
|
23
|
+
@exit_code = exit_code
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A Ruby Raider project that holds flows in flows/<name>_flow.rb.
|
|
28
|
+
class Project
|
|
29
|
+
UNSUPPORTED = 'Flows need a Selenium web project made with Ruby Raider 4.0 or later'
|
|
30
|
+
GITIGNORE_LINES = %w[flow-results/ .raider/flows/].freeze
|
|
31
|
+
TEMPLATES = {
|
|
32
|
+
'flows/flow_helper.rb' => 'flow_helper.tt',
|
|
33
|
+
'flows/support/run.rb' => 'run.tt'
|
|
34
|
+
}.freeze
|
|
35
|
+
# gem 'ruby_raider', '~> 4.0' (any quotes); captures the major and minor
|
|
36
|
+
PIN = /^(\s*gem\s+(["'])ruby_raider\2\s*,\s*(["']))~>\s*(\d+)\.(\d+)(?:\.\d+)?(\3)/
|
|
37
|
+
FLOWS_PIN = [4, 1].freeze
|
|
38
|
+
|
|
39
|
+
attr_reader :root
|
|
40
|
+
|
|
41
|
+
def initialize(path)
|
|
42
|
+
@root = File.realpath(path)
|
|
43
|
+
rescue SystemCallError
|
|
44
|
+
raise Error, "Project folder not found: #{path}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def path(relative) = File.join(root, relative)
|
|
48
|
+
|
|
49
|
+
# The flow file for a name, realpath-checked inside the project.
|
|
50
|
+
def flow_file(name)
|
|
51
|
+
raise Error, "Invalid flow name #{name.inspect}: use 1 to 60 of a-z, 0-9 and _ (not env)" unless Flows.valid_name?(name)
|
|
52
|
+
|
|
53
|
+
file = path("flows/#{name}_flow.rb")
|
|
54
|
+
raise Error, "No flow #{name}: flows/#{name}_flow.rb not found" unless File.file?(file)
|
|
55
|
+
|
|
56
|
+
real = File.realpath(file)
|
|
57
|
+
raise Error, "flows/#{name}_flow.rb points outside the project" unless real.start_with?("#{root}#{File::SEPARATOR}")
|
|
58
|
+
|
|
59
|
+
real
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def flow_names
|
|
63
|
+
Dir.glob(path('flows/*_flow.rb')).map { |file| File.basename(file, '_flow.rb') }.select { |name| Flows.valid_name?(name) }.sort
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def set_up? = TEMPLATES.keys.all? { |file| File.file?(path(file)) }
|
|
67
|
+
|
|
68
|
+
# nil when flows can be added, otherwise why not
|
|
69
|
+
def unsupported_reason
|
|
70
|
+
gemfile = read('Gemfile')
|
|
71
|
+
return UNSUPPORTED unless gemfile && selenium?(gemfile) && runtime?
|
|
72
|
+
return UNSUPPORTED if old_pin?(gemfile)
|
|
73
|
+
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Returns the exit code: 0 done (or already done), 2 refused.
|
|
78
|
+
def init(out: $stdout, err: $stderr)
|
|
79
|
+
reason = unsupported_reason
|
|
80
|
+
if reason
|
|
81
|
+
err.puts(reason)
|
|
82
|
+
return 2
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
created = write_templates
|
|
86
|
+
updated = add_gitignore_lines
|
|
87
|
+
pinned = pin_gem
|
|
88
|
+
updated << 'Gemfile' if pinned
|
|
89
|
+
report(out, created, updated, pinned)
|
|
90
|
+
warn_unknown_pin(err)
|
|
91
|
+
0
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def template_dir = File.expand_path('../../generators/templates/flows', __dir__)
|
|
97
|
+
|
|
98
|
+
def read(relative)
|
|
99
|
+
file = path(relative)
|
|
100
|
+
File.file?(file) ? File.read(file) : nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def selenium?(gemfile)
|
|
104
|
+
gemfile.match?(/["']selenium-webdriver["']/) && !gemfile.match?(/["']appium_lib["']/)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# 4.0 projects load the runtime from the driver helper; 3.x ones used WaitHelper
|
|
108
|
+
def runtime?
|
|
109
|
+
read('helpers/driver_helper.rb').to_s.include?("require 'ruby_raider/runtime'")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def old_pin?(gemfile)
|
|
113
|
+
match = gemfile.match(PIN)
|
|
114
|
+
match ? match[4].to_i < FLOWS_PIN.first : false
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def write_templates
|
|
118
|
+
TEMPLATES.filter_map do |relative, template|
|
|
119
|
+
file = path(relative)
|
|
120
|
+
next if File.exist?(file)
|
|
121
|
+
|
|
122
|
+
FileUtils.mkdir_p(File.dirname(file))
|
|
123
|
+
File.write(file, ERB.new(File.read(File.join(template_dir, template)), trim_mode: '-').result)
|
|
124
|
+
relative
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def add_gitignore_lines
|
|
129
|
+
file = path('.gitignore')
|
|
130
|
+
content = File.file?(file) ? File.read(file) : ''
|
|
131
|
+
missing = GITIGNORE_LINES - content.lines.map(&:strip)
|
|
132
|
+
return [] if missing.empty?
|
|
133
|
+
|
|
134
|
+
prefix = content.empty? || content.end_with?("\n") ? '' : "\n"
|
|
135
|
+
File.write(file, "#{content}#{prefix}#{missing.join("\n")}\n")
|
|
136
|
+
['.gitignore']
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Raises a ~> 4.0 pin to ~> 4.1 so `bundle exec raider flow` runs a gem that has flows.
|
|
140
|
+
def pin_gem
|
|
141
|
+
gemfile = read('Gemfile').to_s
|
|
142
|
+
match = gemfile.match(PIN)
|
|
143
|
+
return false unless match && ([match[4].to_i, match[5].to_i] <=> FLOWS_PIN).negative?
|
|
144
|
+
|
|
145
|
+
File.write(path('Gemfile'), gemfile.sub(PIN) { "#{match[1]}~> #{FLOWS_PIN.join('.')}#{match[6]}" })
|
|
146
|
+
true
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# gem 'ruby_raider', '4.0.1' or path: ... isn't raised here, and an old gem has no `raider flow`
|
|
150
|
+
def warn_unknown_pin(err)
|
|
151
|
+
gemfile = read('Gemfile').to_s
|
|
152
|
+
return if gemfile.match?(PIN) || !gemfile.match?(/^\s*gem\s+["']ruby_raider["']/)
|
|
153
|
+
|
|
154
|
+
err.puts("Check that the ruby_raider line in your Gemfile allows #{FLOWS_PIN.join('.')} or later, then run bundle install")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def report(out, created, updated, pinned)
|
|
158
|
+
if created.empty? && updated.empty?
|
|
159
|
+
out.puts('Flows already set up')
|
|
160
|
+
return
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
created.each { |file| out.puts("Created #{file}") }
|
|
164
|
+
updated.each { |file| out.puts("Updated #{file}") }
|
|
165
|
+
out.puts('Run bundle install') if pinned
|
|
166
|
+
out.puts('Write a flow in flows/<name>_flow.rb, then run it with: raider flow run <name>')
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module RubyRaider
|
|
6
|
+
module Flows
|
|
7
|
+
# Reads .raider/flows/<name>/runs.jsonl, which the project's flows/support/run.rb appends to.
|
|
8
|
+
class RunLog
|
|
9
|
+
MAX_BYTES = 1024 * 1024
|
|
10
|
+
NOFOLLOW = defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0
|
|
11
|
+
NONBLOCK = defined?(File::NONBLOCK) ? File::NONBLOCK : 0
|
|
12
|
+
SUMMARY = %w[status finished_at failed_step results_dir].freeze
|
|
13
|
+
|
|
14
|
+
# [{name, file, last: {status, finished_at, failed_step, results_dir} | nil}], the shape `raider flow list --json` prints
|
|
15
|
+
def self.summaries(project)
|
|
16
|
+
project.flow_names.map do |name|
|
|
17
|
+
last = new(project, name).last_result
|
|
18
|
+
{ 'name' => name, 'file' => "flows/#{name}_flow.rb", 'last' => last && SUMMARY.to_h { |key| [key, last[key]] } }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(project, name)
|
|
23
|
+
@file = project.path(".raider/flows/#{name}/runs.jsonl")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Every readable line in the last MAX_BYTES, oldest first; a broken line is skipped
|
|
27
|
+
def lines
|
|
28
|
+
tail.lines.filter_map do |line|
|
|
29
|
+
parsed = JSON.parse(line)
|
|
30
|
+
parsed if parsed.is_a?(Hash)
|
|
31
|
+
rescue JSON::ParserError
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def last = lines.last
|
|
37
|
+
|
|
38
|
+
# The last run that ran: a busy line only says another run held the lock
|
|
39
|
+
def last_result = lines.reverse.find { |line| line['status'] != 'busy' }
|
|
40
|
+
|
|
41
|
+
def find(run_id) = lines.reverse.find { |line| line['run_id'] == run_id }
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Read through one handle that is not a link, so the checks and the read see the same file
|
|
46
|
+
def tail
|
|
47
|
+
File.open(@file, File::RDONLY | NONBLOCK | NOFOLLOW) do |file|
|
|
48
|
+
stat = file.stat
|
|
49
|
+
return '' unless stat.file?
|
|
50
|
+
|
|
51
|
+
skip = [stat.size - MAX_BYTES, 0].max
|
|
52
|
+
file.seek(skip)
|
|
53
|
+
text = file.read.to_s.force_encoding(Encoding::UTF_8)
|
|
54
|
+
skip.positive? ? text.partition("\n").last : text
|
|
55
|
+
end
|
|
56
|
+
rescue SystemCallError
|
|
57
|
+
''
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|