ruby_raider 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/e2e_tests.yml +3 -0
  3. data/README.md +52 -53
  4. data/RELEASE.md +14 -1
  5. data/lib/commands/schedule_commands.rb +84 -0
  6. data/lib/generators/templates/common/git_ignore.tt +1 -0
  7. data/lib/ruby_raider/runtime/retry_reset.rb +32 -0
  8. data/lib/ruby_raider/runtime/rspec.rb +1 -0
  9. data/lib/ruby_raider/runtime/show_browser.rb +53 -0
  10. data/lib/ruby_raider/runtime.rb +2 -0
  11. data/lib/ruby_raider.rb +22 -7
  12. data/lib/utilities/schedules/adapter.rb +22 -0
  13. data/lib/utilities/schedules/base.rb +27 -0
  14. data/lib/utilities/schedules/kind.rb +74 -0
  15. data/lib/utilities/schedules/legacy.rb +196 -0
  16. data/lib/utilities/schedules/legacy_stub.rb +62 -0
  17. data/lib/utilities/{flows → schedules}/next_run.rb +2 -2
  18. data/lib/utilities/{flows → schedules}/notifier.rb +6 -6
  19. data/lib/utilities/{flows → schedules}/os/command_runner.rb +1 -1
  20. data/lib/utilities/{flows → schedules}/os/cron.rb +27 -17
  21. data/lib/utilities/{flows → schedules}/os/launchd.rb +32 -14
  22. data/lib/utilities/{flows → schedules}/os/linux.rb +23 -5
  23. data/lib/utilities/{flows → schedules}/os/systemd.rb +33 -17
  24. data/lib/utilities/{flows → schedules}/os/task_scheduler.rb +36 -20
  25. data/lib/utilities/schedules/os_enumerator.rb +30 -0
  26. data/lib/utilities/schedules/project.rb +70 -0
  27. data/lib/utilities/schedules/rule.rb +162 -0
  28. data/lib/utilities/schedules/run_lock.rb +160 -0
  29. data/lib/utilities/schedules/run_log.rb +122 -0
  30. data/lib/utilities/schedules/run_report.rb +139 -0
  31. data/lib/utilities/schedules/run_wrapper.rb +216 -0
  32. data/lib/utilities/schedules/schedule.rb +89 -0
  33. data/lib/utilities/schedules/schedule_store.rb +53 -0
  34. data/lib/utilities/schedules/scheduler.rb +178 -0
  35. data/lib/utilities/schedules/scheduler_steps.rb +69 -0
  36. data/lib/utilities/schedules/selection.rb +133 -0
  37. data/lib/utilities/schedules/spawn.rb +68 -0
  38. data/lib/utilities/{flows/schedule_store.rb → schedules/store.rb} +17 -13
  39. data/lib/version +1 -1
  40. data/ruby_raider.gemspec +1 -0
  41. data/sig/commands/schedule_commands.rbs +12 -0
  42. data/sig/ruby_raider/runtime/retry_reset.rbs +7 -0
  43. data/sig/ruby_raider/runtime/show_browser.rbs +27 -0
  44. data/sig/ruby_raider.rbs +4 -0
  45. data/sig/utilities/schedules/adapter.rbs +5 -0
  46. data/sig/utilities/schedules/base.rbs +15 -0
  47. data/sig/utilities/schedules/kind.rbs +32 -0
  48. data/sig/utilities/schedules/legacy.rbs +45 -0
  49. data/sig/utilities/schedules/legacy_stub.rbs +15 -0
  50. data/sig/utilities/{flows → schedules}/next_run.rbs +3 -3
  51. data/sig/utilities/{flows → schedules}/notifier.rbs +1 -1
  52. data/sig/utilities/{flows → schedules}/os/command_runner.rbs +1 -1
  53. data/sig/utilities/{flows → schedules}/os/cron.rbs +7 -7
  54. data/sig/utilities/{flows → schedules}/os/launchd.rbs +7 -6
  55. data/sig/utilities/{flows → schedules}/os/linux.rbs +10 -4
  56. data/sig/utilities/{flows → schedules}/os/systemd.rbs +8 -7
  57. data/sig/utilities/{flows → schedules}/os/task_scheduler.rbs +13 -10
  58. data/sig/utilities/schedules/os_enumerator.rbs +12 -0
  59. data/sig/utilities/schedules/project.rbs +22 -0
  60. data/sig/utilities/{flows/schedule.rbs → schedules/rule.rbs} +23 -12
  61. data/sig/utilities/schedules/run_lock.rbs +29 -0
  62. data/sig/utilities/schedules/run_log.rbs +33 -0
  63. data/sig/utilities/schedules/run_report.rbs +34 -0
  64. data/sig/utilities/schedules/run_wrapper.rbs +45 -0
  65. data/sig/utilities/schedules/schedule.rbs +27 -0
  66. data/sig/utilities/schedules/schedule_store.rbs +21 -0
  67. data/sig/utilities/{flows → schedules}/scheduler.rbs +22 -15
  68. data/sig/utilities/schedules/scheduler_steps.rbs +22 -0
  69. data/sig/utilities/schedules/selection.rbs +36 -0
  70. data/sig/utilities/schedules/spawn.rbs +10 -0
  71. data/sig/utilities/{flows/schedule_store.rbs → schedules/store.rbs} +14 -9
  72. data/spec/commands/schedule_commands_spec.rb +284 -0
  73. data/spec/integration/content/common_content_spec.rb +8 -2
  74. data/spec/integration/content/gemfile_content_spec.rb +2 -2
  75. data/spec/integration/end_to_end_spec.rb +0 -63
  76. data/spec/runtime/retry_reset_spec.rb +112 -0
  77. data/spec/runtime/show_browser_project_spec.rb +70 -0
  78. data/spec/runtime/show_browser_spec.rb +101 -0
  79. data/spec/utilities/schedules/kind_spec.rb +81 -0
  80. data/spec/utilities/schedules/legacy_spec.rb +433 -0
  81. data/spec/utilities/schedules/legacy_stub_spec.rb +144 -0
  82. data/spec/utilities/{flows → schedules}/next_run_spec.rb +5 -5
  83. data/spec/utilities/{flows → schedules}/notifier_spec.rb +3 -4
  84. data/spec/utilities/{flows → schedules}/os/command_runner_spec.rb +2 -3
  85. data/spec/utilities/{flows → schedules}/os/cron_spec.rb +34 -29
  86. data/spec/utilities/{flows → schedules}/os/launchd_spec.rb +42 -29
  87. data/spec/utilities/{flows → schedules}/os/linux_spec.rb +27 -13
  88. data/spec/utilities/schedules/os/list_ids_spec.rb +202 -0
  89. data/spec/utilities/{flows → schedules}/os/systemd_spec.rb +35 -31
  90. data/spec/utilities/{flows → schedules}/os/task_scheduler_spec.rb +40 -34
  91. data/spec/utilities/schedules/os_entry_spec.rb +54 -0
  92. data/spec/utilities/schedules/os_enumerator_spec.rb +57 -0
  93. data/spec/utilities/schedules/project_spec.rb +125 -0
  94. data/spec/utilities/schedules/run_lock_spec.rb +143 -0
  95. data/spec/utilities/schedules/run_log_spec.rb +83 -0
  96. data/spec/utilities/schedules/run_report_spec.rb +150 -0
  97. data/spec/utilities/schedules/run_wrapper_integration_spec.rb +166 -0
  98. data/spec/utilities/schedules/run_wrapper_spec.rb +430 -0
  99. data/spec/utilities/schedules/schedule_spec.rb +120 -0
  100. data/spec/utilities/schedules/schedule_store_spec.rb +151 -0
  101. data/spec/utilities/schedules/scheduler_launchd_spec.rb +77 -0
  102. data/spec/utilities/schedules/scheduler_spec.rb +445 -0
  103. data/spec/utilities/schedules/selection_spec.rb +176 -0
  104. data/spec/utilities/schedules/spawn_spec.rb +104 -0
  105. metadata +106 -53
  106. data/lib/commands/flow_commands.rb +0 -104
  107. data/lib/generators/templates/flows/flow_helper.tt +0 -42
  108. data/lib/generators/templates/flows/run.tt +0 -332
  109. data/lib/utilities/flows/project.rb +0 -170
  110. data/lib/utilities/flows/run_log.rb +0 -61
  111. data/lib/utilities/flows/runner.rb +0 -206
  112. data/lib/utilities/flows/schedule.rb +0 -221
  113. data/lib/utilities/flows/scheduler.rb +0 -187
  114. data/sig/commands/flow_commands.rbs +0 -13
  115. data/sig/utilities/flows/project.rbs +0 -46
  116. data/sig/utilities/flows/run_log.rbs +0 -22
  117. data/sig/utilities/flows/runner.rbs +0 -35
  118. data/spec/commands/flow_commands_spec.rb +0 -128
  119. data/spec/generators/flow_helper_spec.rb +0 -40
  120. data/spec/integration/flows/flow_project.rb +0 -62
  121. data/spec/integration/flows/run_command_spec.rb +0 -91
  122. data/spec/integration/flows/run_lifecycle_spec.rb +0 -383
  123. data/spec/utilities/flows/project_spec.rb +0 -178
  124. data/spec/utilities/flows/run_log_spec.rb +0 -47
  125. data/spec/utilities/flows/runner_spec.rb +0 -313
  126. data/spec/utilities/flows/schedule_spec.rb +0 -233
  127. data/spec/utilities/flows/schedule_store_spec.rb +0 -137
  128. data/spec/utilities/flows/scheduler_spec.rb +0 -329
@@ -0,0 +1,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