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
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require 'shellwords'
|
|
4
4
|
require 'tmpdir'
|
|
5
|
-
require_relative '../../../../lib/utilities/
|
|
5
|
+
require_relative '../../../../lib/utilities/schedules/os/cron'
|
|
6
|
+
require_relative '../../../../lib/utilities/schedules/schedule'
|
|
6
7
|
|
|
7
|
-
# Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
|
|
8
8
|
# The fake runner and project folders sit next to the adapter under test
|
|
9
9
|
# rubocop:disable RSpec/MultipleMemoizedHelpers
|
|
10
|
-
RSpec.describe RubyRaider::
|
|
10
|
+
RSpec.describe RubyRaider::Schedules::Os::Cron do # rubocop:disable RSpec/FilePath
|
|
11
11
|
# Plays crontab: -l prints the current table (or fails), - stores stdin; never runs anything
|
|
12
12
|
let(:fake_runner_class) do
|
|
13
13
|
Class.new do
|
|
@@ -27,22 +27,24 @@ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
|
|
|
27
27
|
stderr, status = answers.fetch(verb) { missing ? ['no crontab for raider', 1] : ['', 0] }
|
|
28
28
|
stdout = verb == '-l' ? table.to_s : ''
|
|
29
29
|
self.table = stdin_data if verb == '-' && status.zero?
|
|
30
|
-
RubyRaider::
|
|
30
|
+
RubyRaider::Schedules::Os::CommandRunner::Result.new(stdout: stdout, stderr: stderr, exitstatus: status)
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
let(:hostile) { %q('; rm -rf ~; ' </string> %h $(id) " \\ &) }
|
|
36
|
-
let(:tmp) { File.realpath(Dir.mktmpdir('
|
|
36
|
+
let(:tmp) { File.realpath(Dir.mktmpdir('schedules-cron')) }
|
|
37
37
|
let(:project_dir) { File.join(tmp, 'plain') }
|
|
38
38
|
let(:runner) { fake_runner_class.new }
|
|
39
|
-
let(:cron) { described_class.new(runner: runner) }
|
|
39
|
+
let(:cron) { described_class.new(kind: RubyRaider::Schedules::Kind::TESTS, runner: runner) }
|
|
40
40
|
let(:now) { Time.utc(2026, 11, 9, 10, 0, 0) }
|
|
41
41
|
|
|
42
|
-
def schedule(dir: project_dir, name: 'report', **options)
|
|
42
|
+
def schedule(dir: project_dir, name: 'report', gem_env: {}, bundle: '/opt/ruby/bin/bundle', **options)
|
|
43
43
|
FileUtils.mkdir_p(dir)
|
|
44
|
-
|
|
45
|
-
RubyRaider::
|
|
44
|
+
# Hermetic: the GEM_HOME/GEM_PATH and bundle this spec runs with never reach the entry; a spec passes its own
|
|
45
|
+
allow(RubyRaider::Schedules::Schedule).to receive_messages(captured_gem_env: gem_env, bundle_path: bundle)
|
|
46
|
+
project = RubyRaider::Schedules::Project.new(File.realpath(dir))
|
|
47
|
+
RubyRaider::Schedules::Schedule.build(project: project, name: name, every: 'day', at: '08:00', now: now, **options)
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
# What cron (cronie/vixie do_command.c) hands to /bin/sh: a backslash escapes the next character, an
|
|
@@ -76,7 +78,7 @@ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
|
|
|
76
78
|
'>>', item.log_path, '2>&1']
|
|
77
79
|
end
|
|
78
80
|
|
|
79
|
-
def block(item) = "# raider-
|
|
81
|
+
def block(item) = "# raider-schedule #{item.os_id}\n#{cron.line(item)}\n"
|
|
80
82
|
|
|
81
83
|
after { FileUtils.rm_rf(tmp) }
|
|
82
84
|
|
|
@@ -103,27 +105,30 @@ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
|
|
|
103
105
|
end
|
|
104
106
|
|
|
105
107
|
it 'keeps GEM_HOME and GEM_PATH in the environment when they were captured' do
|
|
106
|
-
|
|
108
|
+
item = schedule(gem_env: { 'GEM_HOME' => '/g/%home', 'GEM_PATH' => '/g/path' })
|
|
107
109
|
|
|
108
|
-
expect(fields_and_words(cron.line(
|
|
110
|
+
expect(fields_and_words(cron.line(item)).last).to include('GEM_HOME=/g/%home', 'GEM_PATH=/g/path')
|
|
109
111
|
end
|
|
110
112
|
|
|
111
113
|
it 'refuses a ruby path env would read as a variable' do
|
|
112
114
|
plain = schedule
|
|
113
|
-
item = RubyRaider::
|
|
115
|
+
item = RubyRaider::Schedules::Schedule.from_h(plain.to_h.merge('ruby' => '/opt/a=b/ruby'), plain.project_root)
|
|
114
116
|
|
|
115
|
-
expect { cron.line(item) }.to raise_error(RubyRaider::
|
|
117
|
+
expect { cron.line(item) }.to raise_error(RubyRaider::Schedules::Error, /cron can not run ruby from .*a=b/)
|
|
116
118
|
end
|
|
117
119
|
|
|
118
|
-
describe 'with hostile values in
|
|
119
|
-
let(:item)
|
|
120
|
+
describe 'with hostile values in the bundle path, GEM_HOME and the project path' do
|
|
121
|
+
let(:item) do
|
|
122
|
+
schedule(dir: File.join(tmp, "p#{hostile}<", "string> #{hostile} 100%"), bundle: "/opt/#{hostile} 50%/bundle",
|
|
123
|
+
gem_env: { 'GEM_HOME' => "/g/#{hostile} 25%" })
|
|
124
|
+
end
|
|
120
125
|
let(:text) { cron.line(item) }
|
|
121
126
|
|
|
122
127
|
it 'gives back the exact command words after cron and the shell read it' do
|
|
123
128
|
fields, words = fields_and_words(text)
|
|
124
129
|
|
|
125
130
|
expect([fields, words]).to eq([%w[0 8 * * *], expected_words(item)])
|
|
126
|
-
expect(words).to include("
|
|
131
|
+
expect(words).to include("/opt/#{hostile} 50%/bundle", "GEM_HOME=/g/#{hostile} 25%")
|
|
127
132
|
expect(item.working_directory).to include('</string>', "'; rm -rf ~; '", '$(id)', '%h', '"', '\\', '&', ' ')
|
|
128
133
|
end
|
|
129
134
|
|
|
@@ -145,7 +150,7 @@ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
|
|
|
145
150
|
|
|
146
151
|
it 'puts the block first and keeps every other byte, CRLF and a missing last newline included' do
|
|
147
152
|
item = schedule
|
|
148
|
-
other = "SHELL=/bin/fish\r\nMAILTO=\"\"\r\n# raider-
|
|
153
|
+
other = "SHELL=/bin/fish\r\nMAILTO=\"\"\r\n# raider-schedule dev.rubyraider.schedule.0123456789.other\n1 2 * * * echo % \\%\n5 5 * * * last"
|
|
149
154
|
runner.table = other
|
|
150
155
|
|
|
151
156
|
cron.install(item)
|
|
@@ -164,7 +169,7 @@ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
|
|
|
164
169
|
expect(runner.table).not_to include(daily)
|
|
165
170
|
end
|
|
166
171
|
|
|
167
|
-
it
|
|
172
|
+
it "keeps another schedule's block" do
|
|
168
173
|
other = schedule(name: 'nightly')
|
|
169
174
|
runner.table = block(other)
|
|
170
175
|
|
|
@@ -174,16 +179,16 @@ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
|
|
|
174
179
|
end
|
|
175
180
|
|
|
176
181
|
it 'refuses a line vixie cron would cut short and writes nothing' do
|
|
177
|
-
item = schedule(
|
|
182
|
+
item = schedule(gem_env: { 'GEM_HOME' => "/g/#{'x' * 900}" })
|
|
178
183
|
|
|
179
|
-
expect { cron.install(item) }.to raise_error(RubyRaider::
|
|
184
|
+
expect { cron.install(item) }.to raise_error(RubyRaider::Schedules::Error, /crontab line is \d{4} bytes, over 990/)
|
|
180
185
|
expect(runner.calls).to be_empty
|
|
181
186
|
end
|
|
182
187
|
|
|
183
188
|
it 'raises with the scheduler error when crontab -l fails for another reason' do
|
|
184
189
|
runner.answers['-l'] = ["crontab: permission denied\e[2J", 1]
|
|
185
190
|
|
|
186
|
-
expect { cron.install(schedule) }.to raise_error(RubyRaider::
|
|
191
|
+
expect { cron.install(schedule) }.to raise_error(RubyRaider::Schedules::Error, /cron could not read .*permission denied\?\[2J/)
|
|
187
192
|
expect(runner.calls).to eq([%w[crontab -l]])
|
|
188
193
|
end
|
|
189
194
|
|
|
@@ -191,7 +196,7 @@ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
|
|
|
191
196
|
runner.table = "1 * * * * a\n"
|
|
192
197
|
runner.answers['-'] = ['crontab: errors in crontab file', 1]
|
|
193
198
|
|
|
194
|
-
expect { cron.install(schedule) }.to raise_error(RubyRaider::
|
|
199
|
+
expect { cron.install(schedule) }.to raise_error(RubyRaider::Schedules::Error, /cron could not save .*errors in crontab file/)
|
|
195
200
|
expect(runner.table).to eq("1 * * * * a\n")
|
|
196
201
|
end
|
|
197
202
|
end
|
|
@@ -217,7 +222,7 @@ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
|
|
|
217
222
|
it 'keeps an unrelated line that follows an orphaned tag' do
|
|
218
223
|
item = schedule
|
|
219
224
|
other = schedule(name: 'nightly')
|
|
220
|
-
runner.table = "# raider-
|
|
225
|
+
runner.table = "# raider-schedule #{item.os_id}\n#{cron.line(other)}\n1 * * * * a\n"
|
|
221
226
|
|
|
222
227
|
expect(cron.remove(item.os_id)).to be(true)
|
|
223
228
|
expect(runner.table).to eq("#{cron.line(other)}\n1 * * * * a\n")
|
|
@@ -226,7 +231,7 @@ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
|
|
|
226
231
|
it 'keeps a line under its tag that is not a schedule run' do
|
|
227
232
|
item = schedule
|
|
228
233
|
manual = cron.line(item).sub(' --trigger schedule', ' --trigger manual')
|
|
229
|
-
runner.table = "# raider-
|
|
234
|
+
runner.table = "# raider-schedule #{item.os_id}\n#{manual}\n"
|
|
230
235
|
|
|
231
236
|
expect(cron.remove(item.os_id)).to be(true)
|
|
232
237
|
expect(runner.table).to eq("#{manual}\n")
|
|
@@ -234,14 +239,14 @@ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
|
|
|
234
239
|
|
|
235
240
|
it 'removes a tag line left without its line' do
|
|
236
241
|
item = schedule
|
|
237
|
-
runner.table = "1 * * * * a\n# raider-
|
|
242
|
+
runner.table = "1 * * * * a\n# raider-schedule #{item.os_id}"
|
|
238
243
|
|
|
239
244
|
expect(cron.remove(item.os_id)).to be(true)
|
|
240
245
|
expect(runner.table).to eq("1 * * * * a\n")
|
|
241
246
|
end
|
|
242
247
|
|
|
243
248
|
it 'is false and writes nothing when its block is not there' do
|
|
244
|
-
runner.table = "1 * * * * a\n# raider-
|
|
249
|
+
runner.table = "1 * * * * a\n# raider-schedule #{schedule.os_id}x\n"
|
|
245
250
|
|
|
246
251
|
expect(cron.remove(schedule.os_id)).to be(false)
|
|
247
252
|
expect(runner.calls).to eq([%w[crontab -l]])
|
|
@@ -251,8 +256,8 @@ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
|
|
|
251
256
|
expect(cron.remove(schedule.os_id)).to be(false)
|
|
252
257
|
end
|
|
253
258
|
|
|
254
|
-
it 'refuses an os_id that is not a
|
|
255
|
-
expect { cron.remove("x\n* * * * * evil") }.to raise_error(RubyRaider::
|
|
259
|
+
it 'refuses an os_id that is not a schedule id' do
|
|
260
|
+
expect { cron.remove("x\n* * * * * evil") }.to raise_error(RubyRaider::Schedules::Error, /Invalid os_id/)
|
|
256
261
|
expect(runner.calls).to be_empty
|
|
257
262
|
end
|
|
258
263
|
end
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require 'tmpdir'
|
|
4
4
|
require 'rexml/document'
|
|
5
|
-
require_relative '../../../../lib/utilities/
|
|
5
|
+
require_relative '../../../../lib/utilities/schedules/os/launchd'
|
|
6
|
+
require_relative '../../../../lib/utilities/schedules/schedule'
|
|
6
7
|
|
|
7
|
-
# Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
|
|
8
8
|
# The fake runner, temp home and project folders sit next to the adapter under test
|
|
9
9
|
# rubocop:disable RSpec/MultipleMemoizedHelpers
|
|
10
|
-
RSpec.describe RubyRaider::
|
|
10
|
+
RSpec.describe RubyRaider::Schedules::Os::Launchd do # rubocop:disable RSpec/FilePath
|
|
11
11
|
# Records every command and answers from a table (success unless told otherwise); never runs anything
|
|
12
12
|
let(:fake_runner_class) do
|
|
13
13
|
Class.new do
|
|
@@ -24,7 +24,7 @@ RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePat
|
|
|
24
24
|
@stdin << stdin_data
|
|
25
25
|
@seen&.call(argv)
|
|
26
26
|
stderr, status = answers.fetch(argv[1], ['', 0])
|
|
27
|
-
RubyRaider::
|
|
27
|
+
RubyRaider::Schedules::Os::CommandRunner::Result.new(stdout: '', stderr: stderr, exitstatus: status)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def on_call(&block) = @seen = block
|
|
@@ -32,18 +32,20 @@ RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePat
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
let(:hostile) { %q('; rm -rf ~; ' </string> %h $(id) " \\ &) }
|
|
35
|
-
let(:tmp) { File.realpath(Dir.mktmpdir('
|
|
35
|
+
let(:tmp) { File.realpath(Dir.mktmpdir('schedules-launchd')) }
|
|
36
36
|
let(:home) { File.join(tmp, 'home') }
|
|
37
37
|
let(:project_dir) { File.join(tmp, 'plain') }
|
|
38
38
|
let(:runner) { fake_runner_class.new }
|
|
39
|
-
let(:launchd) { described_class.new(runner: runner, home: home, uid: 501) }
|
|
39
|
+
let(:launchd) { described_class.new(kind: RubyRaider::Schedules::Kind::TESTS, runner: runner, home: home, uid: 501) }
|
|
40
40
|
let(:now) { Time.utc(2026, 11, 9, 10, 0, 0) }
|
|
41
41
|
let(:agents) { File.join(home, 'Library', 'LaunchAgents') }
|
|
42
42
|
|
|
43
|
-
def schedule(dir: project_dir, **options)
|
|
43
|
+
def schedule(dir: project_dir, gem_env: {}, bundle: '/opt/ruby/bin/bundle', **options)
|
|
44
44
|
FileUtils.mkdir_p(dir)
|
|
45
|
-
|
|
46
|
-
RubyRaider::
|
|
45
|
+
# Hermetic: the GEM_HOME/GEM_PATH and bundle this spec runs with never reach the entry; a spec passes its own
|
|
46
|
+
allow(RubyRaider::Schedules::Schedule).to receive_messages(captured_gem_env: gem_env, bundle_path: bundle)
|
|
47
|
+
project = RubyRaider::Schedules::Project.new(File.realpath(dir))
|
|
48
|
+
RubyRaider::Schedules::Schedule.build(project: project, name: 'report', every: 'day', at: '08:00', now: now, **options)
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
# plist XML back to Ruby: dict -> Hash, array -> Array, string -> String, integer -> Integer
|
|
@@ -116,7 +118,7 @@ RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePat
|
|
|
116
118
|
end
|
|
117
119
|
|
|
118
120
|
it 'round-trips every field and never sets RunAtLoad' do
|
|
119
|
-
item = schedule(retries: 2,
|
|
121
|
+
item = schedule(retries: 2, selection: RubyRaider::Schedules::Selection.new(kind: 'tag', tag: 'smoke'))
|
|
120
122
|
|
|
121
123
|
expect(parsed(launchd.plist(item))).to eq(
|
|
122
124
|
'Label' => item.os_id, 'ProgramArguments' => item.argv, 'WorkingDirectory' => item.working_directory,
|
|
@@ -126,9 +128,7 @@ RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePat
|
|
|
126
128
|
end
|
|
127
129
|
|
|
128
130
|
it 'keeps GEM_HOME and GEM_PATH in the environment when they were captured' do
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
env = parsed(launchd.plist(schedule))['EnvironmentVariables']
|
|
131
|
+
env = parsed(launchd.plist(schedule(gem_env: { 'GEM_HOME' => '/g/home', 'GEM_PATH' => '/g/path' })))['EnvironmentVariables']
|
|
132
132
|
|
|
133
133
|
expect(env.slice('LANG', 'GEM_HOME', 'GEM_PATH')).to eq('LANG' => 'en_US.UTF-8', 'GEM_HOME' => '/g/home', 'GEM_PATH' => '/g/path')
|
|
134
134
|
end
|
|
@@ -149,15 +149,17 @@ RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePat
|
|
|
149
149
|
expect(days).to eq([0, 1, 6].map { |day| { 'Weekday' => day, 'Hour' => 8, 'Minute' => 0 } })
|
|
150
150
|
end
|
|
151
151
|
|
|
152
|
-
describe 'with hostile values in
|
|
153
|
-
let(:item)
|
|
152
|
+
describe 'with hostile values in the bundle path, GEM_HOME and the project path' do
|
|
153
|
+
let(:item) do
|
|
154
|
+
schedule(dir: File.join(tmp, "p#{hostile}<", "string> #{hostile}"), bundle: "/opt/#{hostile}/bundle", gem_env: { 'GEM_HOME' => "/g/#{hostile}" })
|
|
155
|
+
end
|
|
154
156
|
let(:text) { launchd.plist(item) }
|
|
155
157
|
|
|
156
158
|
it 'round-trips them exactly' do
|
|
157
159
|
data = parsed(text)
|
|
158
160
|
|
|
159
161
|
expect([data['ProgramArguments'], data['WorkingDirectory'], data['StandardOutPath']]).to eq([item.argv, item.working_directory, item.log_path])
|
|
160
|
-
expect(data['ProgramArguments']
|
|
162
|
+
expect([data['ProgramArguments'][1], data['EnvironmentVariables']['GEM_HOME']]).to eq(["/opt/#{hostile}/bundle", "/g/#{hostile}"])
|
|
161
163
|
expect(item.working_directory).to include('</string>', "'; rm -rf ~; '", '$(id)', '%h', '"', '\\', '&')
|
|
162
164
|
end
|
|
163
165
|
|
|
@@ -203,7 +205,7 @@ RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePat
|
|
|
203
205
|
item = schedule
|
|
204
206
|
runner.answers['bootstrap'] = ["Bootstrap failed: 5: Input/output error\e[2J\n", 5]
|
|
205
207
|
|
|
206
|
-
expect { launchd.install(item) }.to raise_error(RubyRaider::
|
|
208
|
+
expect { launchd.install(item) }.to raise_error(RubyRaider::Schedules::Error, %r{launchd.*Bootstrap failed: 5: Input/output error\?\[2J})
|
|
207
209
|
expect(Dir.children(agents)).to be_empty
|
|
208
210
|
end
|
|
209
211
|
|
|
@@ -213,14 +215,14 @@ RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePat
|
|
|
213
215
|
allow(File).to receive(:unlink).and_call_original
|
|
214
216
|
allow(File).to receive(:unlink).with(plist_path(item)).and_raise(Errno::EACCES)
|
|
215
217
|
|
|
216
|
-
expect { launchd.install(item) }.to raise_error(RubyRaider::
|
|
218
|
+
expect { launchd.install(item) }.to raise_error(RubyRaider::Schedules::Error, /Bootstrap failed: 5; also: Could not remove .*Permission denied/)
|
|
217
219
|
end
|
|
218
220
|
|
|
219
|
-
it 'raises a
|
|
221
|
+
it 'raises a schedule error and runs nothing when it can not write the plist' do
|
|
220
222
|
FileUtils.mkdir_p(File.dirname(agents))
|
|
221
223
|
File.write(agents, 'not a folder')
|
|
222
224
|
|
|
223
|
-
expect { launchd.install(schedule) }.to raise_error(RubyRaider::
|
|
225
|
+
expect { launchd.install(schedule) }.to raise_error(RubyRaider::Schedules::Error, /Could not write .*LaunchAgents/)
|
|
224
226
|
expect([runner.calls, File.read(agents)]).to eq([[], 'not a folder'])
|
|
225
227
|
end
|
|
226
228
|
|
|
@@ -230,20 +232,31 @@ RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePat
|
|
|
230
232
|
File.write(plist_path(item), 'old')
|
|
231
233
|
runner.answers['-lint'] = ['bad plist', 1]
|
|
232
234
|
|
|
233
|
-
expect { launchd.install(item) }.to raise_error(RubyRaider::
|
|
235
|
+
expect { launchd.install(item) }.to raise_error(RubyRaider::Schedules::Error, /bad plist/)
|
|
234
236
|
expect([runner.calls.map { |argv| argv[1] }, Dir.children(agents), File.read(plist_path(item))])
|
|
235
237
|
.to eq([['-lint'], [File.basename(plist_path(item))], 'old'])
|
|
236
238
|
end
|
|
237
239
|
end
|
|
238
240
|
|
|
239
241
|
describe '#remove' do
|
|
240
|
-
it '
|
|
242
|
+
it 'deletes the plist, then boots out' do
|
|
241
243
|
item = schedule
|
|
242
244
|
FileUtils.mkdir_p(agents)
|
|
243
245
|
File.write(plist_path(item), 'x')
|
|
246
|
+
seen = []
|
|
247
|
+
runner.on_call { |argv| seen << [argv[1], File.exist?(plist_path(item))] }
|
|
244
248
|
|
|
245
249
|
expect(launchd.remove(item.os_id)).to be(true)
|
|
246
|
-
expect([runner.calls,
|
|
250
|
+
expect([runner.calls, seen]).to eq([[['/bin/launchctl', 'bootout', "gui/501/#{item.os_id}"]], [['bootout', false]]])
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
it 'is true when only the plist was there' do
|
|
254
|
+
item = schedule
|
|
255
|
+
FileUtils.mkdir_p(agents)
|
|
256
|
+
File.write(plist_path(item), 'x')
|
|
257
|
+
runner.answers['bootout'] = ['Boot-out failed: 3: No such process', 3]
|
|
258
|
+
|
|
259
|
+
expect([launchd.remove(item.os_id), File.exist?(plist_path(item))]).to eq([true, false])
|
|
247
260
|
end
|
|
248
261
|
|
|
249
262
|
it 'is true when only the service was loaded' do
|
|
@@ -260,12 +273,12 @@ RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePat
|
|
|
260
273
|
item = schedule
|
|
261
274
|
allow(File).to receive(:unlink).and_raise(Errno::EACCES)
|
|
262
275
|
|
|
263
|
-
expect { launchd.remove(item.os_id) }.to raise_error(RubyRaider::
|
|
264
|
-
expect(runner.calls).to
|
|
276
|
+
expect { launchd.remove(item.os_id) }.to raise_error(RubyRaider::Schedules::Error, /Could not remove .*Permission denied/)
|
|
277
|
+
expect(runner.calls).to be_empty
|
|
265
278
|
end
|
|
266
279
|
|
|
267
|
-
it 'refuses an os_id that is not a
|
|
268
|
-
expect { launchd.remove('../../evil') }.to raise_error(RubyRaider::
|
|
280
|
+
it 'refuses an os_id that is not a schedule id' do
|
|
281
|
+
expect { launchd.remove('../../evil') }.to raise_error(RubyRaider::Schedules::Error, /Invalid os_id/)
|
|
269
282
|
expect(runner.calls).to be_empty
|
|
270
283
|
end
|
|
271
284
|
end
|
|
@@ -278,8 +291,8 @@ RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePat
|
|
|
278
291
|
expect(runner.calls).to eq([['/bin/launchctl', 'print', "gui/501/#{item.os_id}"]])
|
|
279
292
|
end
|
|
280
293
|
|
|
281
|
-
it 'refuses an os_id that is not a
|
|
282
|
-
expect { launchd.present?('../../evil') }.to raise_error(RubyRaider::
|
|
294
|
+
it 'refuses an os_id that is not a schedule id and runs nothing' do
|
|
295
|
+
expect { launchd.present?('../../evil') }.to raise_error(RubyRaider::Schedules::Error, /Invalid os_id/)
|
|
283
296
|
expect(runner.calls).to be_empty
|
|
284
297
|
end
|
|
285
298
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'tmpdir'
|
|
4
|
-
require_relative '../../../../lib/utilities/
|
|
4
|
+
require_relative '../../../../lib/utilities/schedules/os/linux'
|
|
5
|
+
require_relative '../../../../lib/utilities/schedules/schedule'
|
|
5
6
|
|
|
6
|
-
# Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
|
|
7
7
|
# rubocop:disable RSpec/MultipleMemoizedHelpers
|
|
8
|
-
RSpec.describe RubyRaider::
|
|
8
|
+
RSpec.describe RubyRaider::Schedules::Os::Linux do # rubocop:disable RSpec/FilePath
|
|
9
9
|
# Plays systemctl --user and crontab: answers keyed by "systemctl verb" or "crontab -l"; never runs anything
|
|
10
10
|
let(:fake_runner_class) do
|
|
11
11
|
Class.new do
|
|
@@ -24,34 +24,36 @@ RSpec.describe RubyRaider::Flows::Os::Linux do # rubocop:disable RSpec/FilePath
|
|
|
24
24
|
missing = key == 'crontab -l' && table.nil?
|
|
25
25
|
stderr, status = answers.fetch(key) { missing ? ['no crontab for raider', 1] : ['', 0] }
|
|
26
26
|
self.table = stdin_data if key == 'crontab -' && status.zero?
|
|
27
|
-
RubyRaider::
|
|
27
|
+
RubyRaider::Schedules::Os::CommandRunner::Result.new(stdout: key == 'crontab -l' ? table.to_s : '', stderr: stderr, exitstatus: status)
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
let(:tmp) { File.realpath(Dir.mktmpdir('
|
|
32
|
+
let(:tmp) { File.realpath(Dir.mktmpdir('schedules-linux')) }
|
|
33
33
|
let(:runner) { fake_runner_class.new }
|
|
34
34
|
let(:item) do
|
|
35
|
-
|
|
36
|
-
RubyRaider::
|
|
35
|
+
# Hermetic: the GEM_HOME/GEM_PATH and bundle this spec runs with never reach the entry
|
|
36
|
+
allow(RubyRaider::Schedules::Schedule).to receive_messages(captured_gem_env: {}, bundle_path: '/opt/ruby/bin/bundle')
|
|
37
|
+
project = RubyRaider::Schedules::Project.new(tmp)
|
|
38
|
+
RubyRaider::Schedules::Schedule.build(project: project, name: 'report', every: 'day', at: '08:00', now: Time.utc(2026, 11, 9))
|
|
37
39
|
end
|
|
38
|
-
let(:cron_block) { "# raider-
|
|
40
|
+
let(:cron_block) { "# raider-schedule #{item.os_id}\n#{RubyRaider::Schedules::Os::Cron.new(kind: RubyRaider::Schedules::Kind::TESTS).line(item)}\n" }
|
|
39
41
|
let(:no_bus) { ['Failed to connect to bus', 1] }
|
|
40
42
|
|
|
41
43
|
# The systemd units go to a temp home, never the real one
|
|
42
|
-
def pick = described_class.new(runner: runner, systemd: RubyRaider::
|
|
44
|
+
def pick = described_class.new(kind: RubyRaider::Schedules::Kind::TESTS, runner: runner, systemd: RubyRaider::Schedules::Os::Systemd.new(kind: RubyRaider::Schedules::Kind::TESTS, runner: runner, home: tmp, config_home: nil))
|
|
43
45
|
|
|
44
46
|
after { FileUtils.rm_rf(tmp) }
|
|
45
47
|
|
|
46
48
|
it 'uses systemd when the user manager answers' do
|
|
47
49
|
linux = pick
|
|
48
50
|
|
|
49
|
-
expect([linux.label, linux.missed_runs_note, described_class.pick(runner).label]).to eq(['systemd', nil, 'systemd'])
|
|
51
|
+
expect([linux.label, linux.missed_runs_note, described_class.pick(runner, kind: RubyRaider::Schedules::Kind::TESTS).label]).to eq(['systemd', nil, 'systemd'])
|
|
50
52
|
expect(runner.calls.uniq).to eq([%w[systemctl --user show-environment]])
|
|
51
53
|
end
|
|
52
54
|
|
|
53
55
|
it 'falls back to cron when it does not, or systemctl is missing' do
|
|
54
|
-
[no_bus, ['Could not run systemctl', RubyRaider::
|
|
56
|
+
[no_bus, ['Could not run systemctl', RubyRaider::Schedules::Os::CommandRunner::MISSING]].each do |answer|
|
|
55
57
|
runner.answers['systemctl show-environment'] = answer
|
|
56
58
|
|
|
57
59
|
expect([pick.label, pick.missed_runs_note]).to eq(['cron', '(cron: missed runs are skipped)'])
|
|
@@ -111,10 +113,22 @@ RSpec.describe RubyRaider::Flows::Os::Linux do # rubocop:disable RSpec/FilePath
|
|
|
111
113
|
runner.table = "#{cron_block}1 * * * * a\n"
|
|
112
114
|
|
|
113
115
|
expect { pick.remove(item.os_id) }
|
|
114
|
-
.to raise_error(RubyRaider::
|
|
115
|
-
|
|
116
|
+
.to raise_error(RubyRaider::Schedules::Error, 'This schedule is a systemd timer; run raider schedule remove report ' \
|
|
117
|
+
'from a session where systemctl --user works')
|
|
116
118
|
expect([runner.table, File.exist?(File.join(units, "#{item.os_id}.timer"))]).to eq(["1 * * * * a\n", true])
|
|
117
119
|
end
|
|
120
|
+
|
|
121
|
+
it 'names raider schedule list, with no name, for a 4.1 timer' do
|
|
122
|
+
legacy = RubyRaider::Schedules::Kind::FLOW_LEGACY
|
|
123
|
+
os_id = "#{legacy.os_id_prefix}.0123456789.report"
|
|
124
|
+
File.write(File.join(units, "#{os_id}.timer"), 'x')
|
|
125
|
+
linux = described_class.new(kind: legacy, runner: runner,
|
|
126
|
+
systemd: RubyRaider::Schedules::Os::Systemd.new(kind: legacy, runner: runner, home: tmp, config_home: nil))
|
|
127
|
+
|
|
128
|
+
expect { linux.remove(os_id) }
|
|
129
|
+
.to raise_error(RubyRaider::Schedules::Error, 'This schedule is a systemd timer; run raider schedule list ' \
|
|
130
|
+
'from a session where systemctl --user works')
|
|
131
|
+
end
|
|
118
132
|
end
|
|
119
133
|
|
|
120
134
|
it 'does not look for systemd unit files when systemd is the primary' do
|