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
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require_relative 'base'
|
|
6
|
+
require_relative 'kind'
|
|
7
|
+
require_relative 'run_log'
|
|
8
|
+
|
|
9
|
+
module RubyRaider
|
|
10
|
+
module Schedules
|
|
11
|
+
# Who is running the project's tests now, decided before rspec spawns (a busy run must never wipe
|
|
12
|
+
# raider-results/):
|
|
13
|
+
# - .raider/schedules/run.lock, held with flock among `raider schedule run`s; never removed;
|
|
14
|
+
# - .raider/schedules/running.json {pid, name, run_id, started_at}, which the desktop is to write for its
|
|
15
|
+
# own runs too. A live pid there that is not ours means busy. It is read and written only as a regular file
|
|
16
|
+
# (O_NOFOLLOW), made with O_EXCL, and its pid is only checked, never signalled.
|
|
17
|
+
class RunLock
|
|
18
|
+
LOCK = 'run.lock'
|
|
19
|
+
RUNNING = 'running.json'
|
|
20
|
+
MAX_RUNNING_BYTES = 4096
|
|
21
|
+
|
|
22
|
+
def initialize(project)
|
|
23
|
+
@project = project
|
|
24
|
+
@lock = nil
|
|
25
|
+
@run_id = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# true with the lock held and running.json written; false when another run is going
|
|
29
|
+
def acquire(name:, run_id:, started_at:)
|
|
30
|
+
dir = RunLog.private_dir(@project, Kind::TESTS.dir)
|
|
31
|
+
return false unless lock(File.join(dir, LOCK))
|
|
32
|
+
|
|
33
|
+
running = File.join(dir, RUNNING)
|
|
34
|
+
seen = inspect_running(running)
|
|
35
|
+
return busy if seen && (seen[:changed] || live?(seen[:data]))
|
|
36
|
+
return busy if seen && !remove_stale(running, seen[:id])
|
|
37
|
+
|
|
38
|
+
write_running(running, name, run_id, started_at) || busy
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Removes running.json when it is still ours, then frees the lock
|
|
42
|
+
def release
|
|
43
|
+
running = @project.path("#{Kind::TESTS.dir}/#{RUNNING}")
|
|
44
|
+
File.unlink(running) if @run_id && read_running(running)&.fetch('run_id', nil) == @run_id
|
|
45
|
+
rescue SystemCallError
|
|
46
|
+
nil
|
|
47
|
+
ensure
|
|
48
|
+
@run_id = nil
|
|
49
|
+
@lock&.close
|
|
50
|
+
@lock = nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def lock(path)
|
|
56
|
+
file = File.open(path, File::RDWR | File::CREAT | RunLog::NOFOLLOW | RunLog::NONBLOCK, 0o600)
|
|
57
|
+
@lock = file
|
|
58
|
+
raise Error, "#{Kind::TESTS.dir}/#{LOCK} must be a file" unless file.stat.file?
|
|
59
|
+
|
|
60
|
+
file.flock(File::LOCK_EX | File::LOCK_NB) ? true : busy
|
|
61
|
+
rescue Errno::ELOOP, Errno::EMLINK
|
|
62
|
+
raise Error, "#{Kind::TESTS.dir}/#{LOCK} must be a file, not a link"
|
|
63
|
+
rescue Error
|
|
64
|
+
busy
|
|
65
|
+
raise
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def busy
|
|
69
|
+
@lock&.close
|
|
70
|
+
@lock = nil
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# What is at running.json now: its identity (dev, ino, never through a link), its object when it is a regular
|
|
75
|
+
# JSON file, and changed when it was replaced between the lstat and the read. nil when there is none.
|
|
76
|
+
def inspect_running(running)
|
|
77
|
+
stat = File.lstat(running)
|
|
78
|
+
id = [stat.dev, stat.ino]
|
|
79
|
+
data = nil
|
|
80
|
+
changed = false
|
|
81
|
+
if stat.file?
|
|
82
|
+
File.open(running, File::RDONLY | RunLog::NOFOLLOW | RunLog::NONBLOCK) do |file|
|
|
83
|
+
opened = file.stat
|
|
84
|
+
changed = [opened.dev, opened.ino] != id
|
|
85
|
+
data = parse_running(file) unless changed
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
{ id: id, data: data, changed: changed }
|
|
89
|
+
rescue Errno::ENOENT
|
|
90
|
+
nil
|
|
91
|
+
rescue SystemCallError
|
|
92
|
+
{ id: nil, data: nil, changed: true }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Moves the stale file aside and removes it only when it is still the one inspected. The desktop takes no
|
|
96
|
+
# lock, so a running.json it wrote in between is put back (link never replaces a newer one) and counts as busy.
|
|
97
|
+
def remove_stale(running, id)
|
|
98
|
+
aside = "#{running}.#{SecureRandom.hex(6)}.stale"
|
|
99
|
+
File.rename(running, aside)
|
|
100
|
+
stat = File.lstat(aside)
|
|
101
|
+
return File.unlink(aside).positive? if [stat.dev, stat.ino] == id
|
|
102
|
+
|
|
103
|
+
restore(aside, running)
|
|
104
|
+
false
|
|
105
|
+
rescue Errno::ENOENT
|
|
106
|
+
true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def restore(aside, running)
|
|
110
|
+
File.link(aside, running)
|
|
111
|
+
rescue SystemCallError
|
|
112
|
+
nil
|
|
113
|
+
ensure
|
|
114
|
+
File.unlink(aside)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Another process named by running.json is alive. kill(0) only checks: EPERM means alive but not ours to signal.
|
|
118
|
+
def live?(data)
|
|
119
|
+
pid = data&.fetch('pid', nil)
|
|
120
|
+
return false unless pid.is_a?(Integer) && pid.positive? && pid != Process.pid
|
|
121
|
+
|
|
122
|
+
Process.kill(0, pid)
|
|
123
|
+
true
|
|
124
|
+
rescue Errno::EPERM
|
|
125
|
+
true
|
|
126
|
+
rescue SystemCallError
|
|
127
|
+
false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Its JSON object, nil when it is missing, a link, not a regular file, too big or not an object
|
|
131
|
+
def read_running(running)
|
|
132
|
+
File.open(running, File::RDONLY | RunLog::NOFOLLOW | RunLog::NONBLOCK) { |file| parse_running(file) }
|
|
133
|
+
rescue SystemCallError
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The object in an open running.json, nil when it is not a small regular file holding one
|
|
138
|
+
def parse_running(file)
|
|
139
|
+
stat = file.stat
|
|
140
|
+
return nil unless stat.file? && stat.size <= MAX_RUNNING_BYTES
|
|
141
|
+
|
|
142
|
+
data = JSON.parse(file.read.to_s)
|
|
143
|
+
data.is_a?(Hash) ? data : nil
|
|
144
|
+
rescue JSON::ParserError
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# false when another writer made the file first
|
|
149
|
+
def write_running(running, name, run_id, started_at)
|
|
150
|
+
File.open(running, File::WRONLY | File::CREAT | File::EXCL | RunLog::NOFOLLOW, 0o600) do |file|
|
|
151
|
+
file.write(JSON.generate('pid' => Process.pid, 'name' => name, 'run_id' => run_id, 'started_at' => started_at))
|
|
152
|
+
end
|
|
153
|
+
@run_id = run_id
|
|
154
|
+
true
|
|
155
|
+
rescue Errno::EEXIST
|
|
156
|
+
false
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require_relative 'base'
|
|
7
|
+
require_relative 'kind'
|
|
8
|
+
|
|
9
|
+
module RubyRaider
|
|
10
|
+
module Schedules
|
|
11
|
+
# <kind dir>/<name>/runs.jsonl: one JSON line per run, oldest first. `raider schedule run` appends to it
|
|
12
|
+
# (status and counts only, never a message).
|
|
13
|
+
# :reek:TooManyConstants { max_constants: 7 } -- the read cap, the line cap, the files and the open flags
|
|
14
|
+
class RunLog
|
|
15
|
+
MAX_BYTES = 1024 * 1024
|
|
16
|
+
MAX_LINES = 50
|
|
17
|
+
FILE = 'runs.jsonl'
|
|
18
|
+
# A busy run appends without the run lock: appends and the trim at MAX_LINES take this one. It is never
|
|
19
|
+
# renamed (the trim renames a new runs.jsonl into place), so a lock on it keeps excluding.
|
|
20
|
+
LOCK = 'runs.jsonl.lock'
|
|
21
|
+
NOFOLLOW = defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0
|
|
22
|
+
NONBLOCK = defined?(File::NONBLOCK) ? File::NONBLOCK : 0
|
|
23
|
+
|
|
24
|
+
# A 0700 folder <dir>/<name> inside the project; no part of it may be a link. Returns its path.
|
|
25
|
+
def self.private_dir(project, *parts)
|
|
26
|
+
path = project.root
|
|
27
|
+
parts.flat_map { |part| part.split('/') }.each do |part|
|
|
28
|
+
path = File.join(path, part)
|
|
29
|
+
FileUtils.mkdir_p(path)
|
|
30
|
+
raise Error, "#{path.delete_prefix("#{project.root}/")} must be a folder, not a link" unless File.lstat(path).directory?
|
|
31
|
+
end
|
|
32
|
+
File.chmod(0o700, path)
|
|
33
|
+
path
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(project, name, dir: Kind::TESTS.dir)
|
|
37
|
+
@project = project
|
|
38
|
+
@parts = [dir, name]
|
|
39
|
+
@file = project.path("#{dir}/#{name}/#{FILE}")
|
|
40
|
+
@lock = project.path("#{dir}/#{name}/#{LOCK}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Every readable line in the last MAX_BYTES, oldest first; a broken line is skipped
|
|
44
|
+
def lines
|
|
45
|
+
tail.lines.filter_map do |line|
|
|
46
|
+
parsed = JSON.parse(line)
|
|
47
|
+
parsed if parsed.is_a?(Hash)
|
|
48
|
+
rescue JSON::ParserError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def last = lines.last
|
|
54
|
+
|
|
55
|
+
# The last run that ran: a busy line only says another run held the lock
|
|
56
|
+
def last_result = lines.reverse.find { |line| line['status'] != 'busy' }
|
|
57
|
+
|
|
58
|
+
def find(run_id) = lines.reverse.find { |line| line['run_id'] == run_id }
|
|
59
|
+
|
|
60
|
+
# Adds one line, keeping the last MAX_LINES. An append while there is room; otherwise the kept lines are
|
|
61
|
+
# written to a temp file that replaces the log, so a reader never sees half of it. Never through a link.
|
|
62
|
+
def append(line)
|
|
63
|
+
RunLog.private_dir(@project, *@parts)
|
|
64
|
+
locked do
|
|
65
|
+
# A line cut short by a crash is ended first, so the new line stays whole
|
|
66
|
+
raw = tail
|
|
67
|
+
text = "#{JSON.generate(line)}\n"
|
|
68
|
+
text = "\n#{text}" unless raw.empty? || raw.end_with?("\n")
|
|
69
|
+
old = raw.lines
|
|
70
|
+
old.size < MAX_LINES ? add(text) : replace(old.last(MAX_LINES - 1).join + text)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# Holds runs.jsonl.lock (a regular file, never through a link) while the block reads and writes the log
|
|
77
|
+
def locked
|
|
78
|
+
File.open(@lock, File::RDWR | File::CREAT | NOFOLLOW | NONBLOCK, 0o600) do |file|
|
|
79
|
+
raise Error, "#{LOCK} must be a file" unless file.stat.file?
|
|
80
|
+
|
|
81
|
+
file.flock(File::LOCK_EX)
|
|
82
|
+
yield
|
|
83
|
+
end
|
|
84
|
+
rescue Errno::ELOOP, Errno::EMLINK
|
|
85
|
+
raise Error, "#{LOCK} must be a file, not a link"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def add(text)
|
|
89
|
+
File.open(@file, File::WRONLY | File::APPEND | File::CREAT | NOFOLLOW | NONBLOCK, 0o600) do |file|
|
|
90
|
+
raise Error, "#{FILE} must be a file" unless file.stat.file?
|
|
91
|
+
|
|
92
|
+
file.write(text)
|
|
93
|
+
end
|
|
94
|
+
rescue Errno::ELOOP, Errno::EMLINK
|
|
95
|
+
raise Error, "#{FILE} must be a file, not a link"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def replace(text)
|
|
99
|
+
temp = "#{@file}.#{SecureRandom.hex(6)}.tmp"
|
|
100
|
+
File.open(temp, File::WRONLY | File::CREAT | File::EXCL | NOFOLLOW, 0o600) { |file| file.write(text) }
|
|
101
|
+
File.rename(temp, @file)
|
|
102
|
+
ensure
|
|
103
|
+
FileUtils.rm_f(temp) if temp && File.exist?(temp)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Read through one handle that is not a link, so the checks and the read see the same file
|
|
107
|
+
def tail
|
|
108
|
+
File.open(@file, File::RDONLY | NONBLOCK | NOFOLLOW) do |file|
|
|
109
|
+
stat = file.stat
|
|
110
|
+
return '' unless stat.file?
|
|
111
|
+
|
|
112
|
+
skip = [stat.size - MAX_BYTES, 0].max
|
|
113
|
+
file.seek(skip)
|
|
114
|
+
text = file.read.to_s.force_encoding(Encoding::UTF_8)
|
|
115
|
+
skip.positive? ? text.partition("\n").last : text
|
|
116
|
+
end
|
|
117
|
+
rescue SystemCallError
|
|
118
|
+
''
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require 'time'
|
|
7
|
+
require_relative 'base'
|
|
8
|
+
require_relative 'run_log'
|
|
9
|
+
|
|
10
|
+
module RubyRaider
|
|
11
|
+
module Schedules
|
|
12
|
+
# raider-results/run.json of the run that just ended, as raider_reporter wrote it when rspec finished.
|
|
13
|
+
# A run that died before that leaves the previous run's file, so it counts only when its run started at
|
|
14
|
+
# (or up to SLACK before) the spawn; without a start time, when the file is not older than the spawn.
|
|
15
|
+
# raider-results/ is only read, never changed, and nothing in it is read through a link.
|
|
16
|
+
# :reek:TooManyConstants { max_constants: 9 } -- the folder, its files, the caps and the slack
|
|
17
|
+
class RunReport
|
|
18
|
+
DIR = 'raider-results'
|
|
19
|
+
RUN = 'run.json'
|
|
20
|
+
INDEX = 'index.html'
|
|
21
|
+
ATTACHMENTS = 'attachments'
|
|
22
|
+
SLACK = 2
|
|
23
|
+
MAX_RUN_BYTES = 256 * 1024 * 1024
|
|
24
|
+
MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024
|
|
25
|
+
# Past these the attachments are left out of the copy (run.json and index.html still go)
|
|
26
|
+
MAX_ATTACHMENT_DEPTH = 8
|
|
27
|
+
MAX_ATTACHMENT_FILES = 1000
|
|
28
|
+
|
|
29
|
+
attr_reader :counts
|
|
30
|
+
|
|
31
|
+
# The report of the run spawned at since, nil when there is none
|
|
32
|
+
def self.load(project, since:)
|
|
33
|
+
dir = project.path(DIR)
|
|
34
|
+
return nil unless File.lstat(dir).directory?
|
|
35
|
+
|
|
36
|
+
bytes, mtime = read_file(File.join(dir, RUN), MAX_RUN_BYTES)
|
|
37
|
+
data = bytes && JSON.parse(bytes)
|
|
38
|
+
return nil unless data.is_a?(Hash) && data['results'].is_a?(Array) && fresh?(data['run'], mtime, since)
|
|
39
|
+
|
|
40
|
+
new(dir, bytes, data['results'])
|
|
41
|
+
rescue SystemCallError, JSON::ParserError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# [content, mtime] of a regular file opened without following a link, nil when it is not one or too big
|
|
46
|
+
def self.read_file(path, max)
|
|
47
|
+
File.open(path, File::RDONLY | RunLog::NOFOLLOW | RunLog::NONBLOCK) do |file|
|
|
48
|
+
stat = file.stat
|
|
49
|
+
stat.file? && stat.size <= max ? [file.read.to_s, stat.mtime] : nil
|
|
50
|
+
end
|
|
51
|
+
rescue SystemCallError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.fresh?(run, mtime, since)
|
|
56
|
+
started = run.is_a?(Hash) ? run['started_at'] : nil
|
|
57
|
+
return mtime >= since - SLACK unless started.is_a?(String)
|
|
58
|
+
|
|
59
|
+
Time.iso8601(started) >= since - SLACK
|
|
60
|
+
rescue ArgumentError
|
|
61
|
+
false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def initialize(dir, run_json, results)
|
|
65
|
+
@dir = dir
|
|
66
|
+
@run_json = run_json
|
|
67
|
+
statuses = results.map { |result| result.is_a?(Hash) ? result['status'] : nil }
|
|
68
|
+
@counts = { 'total' => statuses.size, 'passed' => statuses.count('passed'),
|
|
69
|
+
'failed' => statuses.count { |status| %w[failed quarantined].include?(status) },
|
|
70
|
+
'skipped' => statuses.count { |status| %w[pending skipped].include?(status) } }.freeze
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Copies run.json, index.html and (when they come to at most MAX_ATTACHMENT_BYTES) the attachments to
|
|
74
|
+
# target, replacing what was there: built in a temp folder beside it, then renamed into place.
|
|
75
|
+
def copy_to(target)
|
|
76
|
+
temp = "#{target}.#{SecureRandom.hex(6)}.tmp"
|
|
77
|
+
Dir.mkdir(temp, 0o700)
|
|
78
|
+
File.binwrite(File.join(temp, RUN), @run_json)
|
|
79
|
+
index = RunReport.read_file(File.join(@dir, INDEX), MAX_RUN_BYTES)
|
|
80
|
+
File.binwrite(File.join(temp, INDEX), index.first) if index
|
|
81
|
+
copy_attachments(temp)
|
|
82
|
+
swap(temp, target)
|
|
83
|
+
true
|
|
84
|
+
ensure
|
|
85
|
+
FileUtils.rm_rf(temp) if temp && File.exist?(temp)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def copy_attachments(temp)
|
|
91
|
+
files = regular_files(File.join(@dir, ATTACHMENTS))
|
|
92
|
+
return unless files && files.sum { |_, size| size } <= MAX_ATTACHMENT_BYTES
|
|
93
|
+
|
|
94
|
+
FileUtils.mkdir_p(File.join(temp, ATTACHMENTS))
|
|
95
|
+
files.each { |relative, size| copy_file(File.join(@dir, ATTACHMENTS, relative), File.join(temp, ATTACHMENTS, relative), size) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Streams one file that is still a regular file when opened (not a link), at most the size counted for
|
|
99
|
+
# it, so a file that grew since does not take the copy past the cap; anything else is skipped
|
|
100
|
+
def copy_file(source, target, size)
|
|
101
|
+
File.open(source, File::RDONLY | RunLog::NOFOLLOW | RunLog::NONBLOCK) do |from|
|
|
102
|
+
next unless from.stat.file?
|
|
103
|
+
|
|
104
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
105
|
+
File.open(target, File::WRONLY | File::CREAT | File::EXCL | RunLog::NOFOLLOW, 0o600) { |to| IO.copy_stream(from, to, size) }
|
|
106
|
+
end
|
|
107
|
+
rescue Errno::ELOOP, Errno::EMLINK, Errno::ENOENT
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# {relative path => size} of the regular files under dir (links and anything else skipped); nil once
|
|
112
|
+
# they pass a cap (bytes, files or folder depth) or when dir is not a folder
|
|
113
|
+
def regular_files(dir, prefix = nil, found = {}, depth = 0)
|
|
114
|
+
return nil if depth > MAX_ATTACHMENT_DEPTH || !File.lstat(dir).directory?
|
|
115
|
+
|
|
116
|
+
Dir.children(dir).sort.each do |child|
|
|
117
|
+
relative = prefix ? File.join(prefix, child) : child
|
|
118
|
+
stat = File.lstat(File.join(dir, child))
|
|
119
|
+
if stat.directory? then return nil unless regular_files(File.join(dir, child), relative, found, depth + 1)
|
|
120
|
+
elsif stat.file? then found[relative] = stat.size
|
|
121
|
+
end
|
|
122
|
+
return nil if found.size > MAX_ATTACHMENT_FILES || found.sum { |_, size| size } > MAX_ATTACHMENT_BYTES
|
|
123
|
+
end
|
|
124
|
+
found
|
|
125
|
+
rescue SystemCallError
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The old copy moves aside first, so target is never half old and half new
|
|
130
|
+
def swap(temp, target)
|
|
131
|
+
old = "#{target}.#{SecureRandom.hex(6)}.old"
|
|
132
|
+
File.rename(target, old) if File.symlink?(target) || File.exist?(target)
|
|
133
|
+
File.rename(temp, target)
|
|
134
|
+
ensure
|
|
135
|
+
FileUtils.rm_rf(old) if old && (File.symlink?(old) || File.exist?(old))
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'time'
|
|
5
|
+
require_relative 'base'
|
|
6
|
+
require_relative 'project'
|
|
7
|
+
require_relative 'schedule_store'
|
|
8
|
+
require_relative 'run_log'
|
|
9
|
+
require_relative 'run_lock'
|
|
10
|
+
require_relative 'run_report'
|
|
11
|
+
require_relative 'notifier'
|
|
12
|
+
require_relative 'spawn'
|
|
13
|
+
|
|
14
|
+
module RubyRaider
|
|
15
|
+
module Schedules
|
|
16
|
+
# `raider schedule run NAME`: what the OS entry runs, and what a person can run by hand. It owns the whole
|
|
17
|
+
# run record (spec §4): it re-validates the entry, takes the lock before rspec spawns (busy never wipes
|
|
18
|
+
# raider-results/), runs `bundle exec rspec` of the selection with retries and headless set through the
|
|
19
|
+
# env, counts raider-results/run.json, appends the run line, copies the report, cleans up and notifies.
|
|
20
|
+
# Exit 0 passed, 1 failed, 2 setup_error (or refused), 3 busy.
|
|
21
|
+
# :reek:TooManyInstanceVariables { max_instance_variables: 7 } -- the project and the injected collaborators
|
|
22
|
+
# :reek:TooManyConstants { max_constants: 6 } -- the exit codes, triggers, results folder, log cap and wording
|
|
23
|
+
class RunWrapper
|
|
24
|
+
EXIT_CODES = { 'passed' => 0, 'failed' => 1, 'setup_error' => 2, 'busy' => 3 }.freeze
|
|
25
|
+
TRIGGERS = %w[manual schedule].freeze
|
|
26
|
+
LOG_BYTES = 1024 * 1024
|
|
27
|
+
MISSING = 'remove the schedule or add it again'
|
|
28
|
+
ZERO = { 'total' => 0, 'passed' => 0, 'failed' => 0, 'skipped' => 0 }.freeze
|
|
29
|
+
# Forward a wrapper INT only when it can't have come from a terminal Ctrl-C, which rspec gets too
|
|
30
|
+
SPAWNER = ->(env, argv, chdir) { Spawn.call(env, argv, chdir, forward_int: !$stdin.tty?) }
|
|
31
|
+
|
|
32
|
+
# spawner: (env, argv, chdir) -> [exit status or nil, stopped]; notifier: nil makes one only when needed
|
|
33
|
+
def initialize(project, out: $stdout, err: $stderr, spawner: SPAWNER, clock: -> { Time.now }, notifier: nil)
|
|
34
|
+
@project = project
|
|
35
|
+
@out = out
|
|
36
|
+
@err = err
|
|
37
|
+
@spawner = spawner
|
|
38
|
+
@clock = clock
|
|
39
|
+
@notifier = notifier
|
|
40
|
+
@lock = RunLock.new(project)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The exit code. Raises Error (exit 2, no run line) for an unknown trigger or a schedule that is not in
|
|
44
|
+
# schedules.yml or no longer valid there.
|
|
45
|
+
def run(name, trigger: 'manual')
|
|
46
|
+
raise Error, "Invalid trigger #{Schedules.printable(trigger.inspect)}: use #{TRIGGERS.join(' or ')}" unless TRIGGERS.include?(trigger)
|
|
47
|
+
|
|
48
|
+
schedule = load(name)
|
|
49
|
+
rotate_log(schedule) if trigger == 'schedule'
|
|
50
|
+
record = { 'run_id' => SecureRandom.hex(8), 'started_at' => stamp, 'trigger' => trigger }
|
|
51
|
+
outcome = attempt(schedule, record)
|
|
52
|
+
finish(schedule, record, outcome)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def load(name)
|
|
58
|
+
schedule = ScheduleStore.new(@project).find(name) if Schedules.valid_name?(name)
|
|
59
|
+
return schedule if schedule
|
|
60
|
+
|
|
61
|
+
raise Error, "No test schedule #{Schedules.printable(name.inspect)} in #{ScheduleStore::DIR}/#{Store::FILE} (or it is not valid): " \
|
|
62
|
+
'see raider schedule list'
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# {status, counts, report?, reason?, stopped?}; the lock is held from before the spawn to the run line
|
|
66
|
+
def attempt(schedule, record)
|
|
67
|
+
outcome = {}
|
|
68
|
+
schedule.selection.check!(@project.root)
|
|
69
|
+
return { status: 'busy' } unless @lock.acquire(name: schedule.name, run_id: record['run_id'], started_at: record['started_at'])
|
|
70
|
+
|
|
71
|
+
outcome = spawn(schedule)
|
|
72
|
+
outcome[:copied] = copy_report(schedule, outcome[:report])
|
|
73
|
+
# A line that can't be written loses only the line: the stop, counts and exit code stay
|
|
74
|
+
append_quietly(schedule, record, outcome)
|
|
75
|
+
outcome[:recorded] = true
|
|
76
|
+
outcome
|
|
77
|
+
rescue Error, SystemCallError => e
|
|
78
|
+
{ status: 'setup_error', reason: Schedules.printable(e.message) }
|
|
79
|
+
rescue SignalException
|
|
80
|
+
stopped(schedule, record, outcome)
|
|
81
|
+
ensure
|
|
82
|
+
@lock.release
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# INT or TERM outside the spawn's traps (the check, the lock, the report copy) is a stop: finish records it
|
|
86
|
+
# and the ensure frees the lock and running.json. A run whose line is written already keeps that outcome.
|
|
87
|
+
def stopped(schedule, record, outcome)
|
|
88
|
+
return outcome.merge(recorded: true) if outcome[:recorded] || RunLog.new(@project, schedule.name).find(record['run_id'])
|
|
89
|
+
|
|
90
|
+
outcome.merge(status: 'setup_error', stopped: true)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def spawn(schedule)
|
|
94
|
+
since = @clock.call
|
|
95
|
+
code, stopped = @spawner.call(env(schedule), argv(schedule), @project.root)
|
|
96
|
+
report = RunReport.load(@project, since: since)
|
|
97
|
+
{ status: status(code, stopped, report&.counts), report: report, stopped: stopped }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A stop wins over everything: an interrupted rspec exits 1 even when what it ran passed
|
|
101
|
+
def status(code, stopped, counts)
|
|
102
|
+
return 'setup_error' if stopped || counts.nil?
|
|
103
|
+
return 'passed' if code&.zero? && counts['total'].positive?
|
|
104
|
+
return 'failed' if code == 1 && counts['failed'].positive?
|
|
105
|
+
|
|
106
|
+
'setup_error'
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def argv(schedule)
|
|
110
|
+
selection = schedule.selection
|
|
111
|
+
paths = case selection.kind
|
|
112
|
+
when 'tag' then ['--tag', selection.tag.to_s]
|
|
113
|
+
when 'files' then selection.files
|
|
114
|
+
else []
|
|
115
|
+
end
|
|
116
|
+
[schedule.ruby, schedule.bundle, 'exec', 'rspec', *paths]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Spec §4a: the inherited values are dropped first (nil unsets), then retries and headless are set.
|
|
120
|
+
# config/config.yml is never written.
|
|
121
|
+
def env(schedule)
|
|
122
|
+
show = schedule.show_browser
|
|
123
|
+
gem_env(schedule).merge('BUNDLE_GEMFILE' => @project.path('Gemfile'), 'RETRY_COUNT' => (schedule.retries + 1).to_s,
|
|
124
|
+
'RSPEC_RETRY_RETRY_COUNT' => nil, 'HEADLESS' => show ? nil : 'true', 'RAIDER_SHOW_BROWSER' => show ? '1' : nil)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The GEM_HOME and GEM_PATH captured at add, as the OS entry sets them (a key not captured is unset), so
|
|
128
|
+
# a run by hand from another Ruby uses the scheduled run's gems. Nothing captured: the caller's stay.
|
|
129
|
+
def gem_env(schedule)
|
|
130
|
+
captured = schedule.gem_env
|
|
131
|
+
captured.empty? ? {} : Rule::GEM_ENV.to_h { |key| [key, captured[key]] }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The report's folder relative to the project, nil when there was no report of this run or it could not be copied
|
|
135
|
+
def copy_report(schedule, report)
|
|
136
|
+
return nil unless report
|
|
137
|
+
|
|
138
|
+
relative = "#{Kind::TESTS.dir}/#{schedule.name}/last"
|
|
139
|
+
RunLog.private_dir(@project, Kind::TESTS.dir, schedule.name)
|
|
140
|
+
report.copy_to(@project.path(relative))
|
|
141
|
+
relative
|
|
142
|
+
rescue Error, SystemCallError => e
|
|
143
|
+
@err.puts("Could not copy the report to #{relative}: #{Schedules.printable(e.message)}")
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def append(schedule, record, outcome)
|
|
148
|
+
line = { 'run_id' => record['run_id'], 'started_at' => record['started_at'], 'finished_at' => stamp, 'status' => outcome[:status],
|
|
149
|
+
'counts' => outcome[:report]&.counts || ZERO, 'trigger' => record['trigger'], 'results_dir' => RunReport::DIR }
|
|
150
|
+
line['report_copy'] = outcome[:copied] if outcome[:copied]
|
|
151
|
+
RunLog.new(@project, schedule.name).append(line)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Busy and early setup errors get their line here, after the lock is gone; the rest have one already
|
|
155
|
+
def finish(schedule, record, outcome)
|
|
156
|
+
status = outcome[:status]
|
|
157
|
+
append_quietly(schedule, record, outcome) unless outcome[:recorded]
|
|
158
|
+
@out.puts("Schedule #{Schedules.printable(schedule.name)} #{result_text(schedule, record, outcome)}")
|
|
159
|
+
notify(schedule, record, outcome) if schedule.notify && !outcome[:stopped] && %w[failed setup_error].include?(status)
|
|
160
|
+
EXIT_CODES.fetch(status)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def append_quietly(schedule, record, outcome)
|
|
164
|
+
append(schedule, record, outcome)
|
|
165
|
+
rescue Error, SystemCallError => e
|
|
166
|
+
@err.puts("Could not record the run: #{Schedules.printable(e.message)}")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def result_text(schedule, record, outcome)
|
|
170
|
+
counts = outcome[:report]&.counts || ZERO
|
|
171
|
+
return 'was stopped' if outcome[:stopped]
|
|
172
|
+
|
|
173
|
+
case outcome[:status]
|
|
174
|
+
when 'passed' then "passed: #{tests(counts['total'])}"
|
|
175
|
+
when 'failed' then "failed: #{failures(counts)}"
|
|
176
|
+
when 'busy' then 'is already running'
|
|
177
|
+
else "could not start: #{start_reason(schedule, record, outcome, long: true)}"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def failures(counts) = "#{counts['failed']} of #{tests(counts['total'])} failed"
|
|
182
|
+
|
|
183
|
+
def tests(total) = total == 1 ? '1 test' : "#{total} tests"
|
|
184
|
+
|
|
185
|
+
# The notifier never raises, so a notification can't change the exit code
|
|
186
|
+
def notify(schedule, record, outcome)
|
|
187
|
+
counts = outcome[:report]&.counts || ZERO
|
|
188
|
+
body = outcome[:status] == 'failed' ? failures(counts) : "Could not start: #{start_reason(schedule, record, outcome, long: false)}"
|
|
189
|
+
(@notifier ||= Notifier.new).notify("Ruby Raider: #{schedule.name} failed", body)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# A refusal names itself ("spec/x_spec.rb not found"); a run that started points at its output. Only a
|
|
193
|
+
# scheduled run outside Windows has a scheduler.log: Windows tasks keep none.
|
|
194
|
+
def start_reason(schedule, record, outcome, long:)
|
|
195
|
+
reason = outcome[:reason]
|
|
196
|
+
return long && reason.end_with?(' not found') ? "#{reason}: #{MISSING}" : reason if reason
|
|
197
|
+
return "see #{Kind::TESTS.dir}/#{schedule.name}/scheduler.log" if record['trigger'] == 'schedule' && !Gem.win_platform?
|
|
198
|
+
|
|
199
|
+
'see the run output'
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# launchd and cron append to scheduler.log through a handle opened before this process started. Renaming
|
|
203
|
+
# the file keeps that handle (POSIX), so the rest of this run's output lands in scheduler.log.1 and the
|
|
204
|
+
# next run starts a new scheduler.log. A link or anything but a regular file is left alone.
|
|
205
|
+
def rotate_log(schedule)
|
|
206
|
+
log = @project.path("#{Kind::TESTS.dir}/#{schedule.name}/scheduler.log")
|
|
207
|
+
stat = File.lstat(log)
|
|
208
|
+
File.rename(log, "#{log}.1") if stat.file? && stat.size > LOG_BYTES
|
|
209
|
+
rescue SystemCallError
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def stamp = @clock.call.getutc.iso8601(3)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|