ruby_raider 4.0.1 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +59 -1
- data/RELEASE.md +12 -0
- data/lib/commands/flow_commands.rb +104 -0
- data/lib/generators/templates/flows/flow_helper.tt +42 -0
- data/lib/generators/templates/flows/run.tt +332 -0
- data/lib/ruby_raider.rb +16 -0
- data/lib/utilities/flows/next_run.rb +61 -0
- data/lib/utilities/flows/notifier.rb +76 -0
- data/lib/utilities/flows/os/command_runner.rb +73 -0
- data/lib/utilities/flows/os/cron.rb +137 -0
- data/lib/utilities/flows/os/launchd.rb +164 -0
- data/lib/utilities/flows/os/linux.rb +63 -0
- data/lib/utilities/flows/os/systemd.rb +178 -0
- data/lib/utilities/flows/os/task_scheduler.rb +179 -0
- data/lib/utilities/flows/project.rb +170 -0
- data/lib/utilities/flows/run_log.rb +61 -0
- data/lib/utilities/flows/runner.rb +206 -0
- data/lib/utilities/flows/schedule.rb +221 -0
- data/lib/utilities/flows/schedule_store.rb +135 -0
- data/lib/utilities/flows/scheduler.rb +187 -0
- data/lib/version +1 -1
- data/sig/commands/flow_commands.rbs +13 -0
- data/sig/ruby_raider.rbs +2 -0
- data/sig/utilities/flows/next_run.rbs +15 -0
- data/sig/utilities/flows/notifier.rbs +29 -0
- data/sig/utilities/flows/os/command_runner.rbs +27 -0
- data/sig/utilities/flows/os/cron.rbs +34 -0
- data/sig/utilities/flows/os/launchd.rbs +45 -0
- data/sig/utilities/flows/os/linux.rbs +27 -0
- data/sig/utilities/flows/os/systemd.rbs +43 -0
- data/sig/utilities/flows/os/task_scheduler.rbs +53 -0
- data/sig/utilities/flows/project.rbs +46 -0
- data/sig/utilities/flows/run_log.rbs +22 -0
- data/sig/utilities/flows/runner.rbs +35 -0
- data/sig/utilities/flows/schedule.rbs +61 -0
- data/sig/utilities/flows/schedule_store.rbs +34 -0
- data/sig/utilities/flows/scheduler.rbs +42 -0
- data/spec/commands/flow_commands_spec.rb +128 -0
- data/spec/generators/flow_helper_spec.rb +40 -0
- data/spec/integration/content/gemfile_content_spec.rb +2 -2
- data/spec/integration/end_to_end_spec.rb +63 -0
- data/spec/integration/flows/flow_project.rb +62 -0
- data/spec/integration/flows/run_command_spec.rb +91 -0
- data/spec/integration/flows/run_lifecycle_spec.rb +383 -0
- data/spec/utilities/flows/next_run_spec.rb +119 -0
- data/spec/utilities/flows/notifier_spec.rb +121 -0
- data/spec/utilities/flows/os/command_runner_spec.rb +56 -0
- data/spec/utilities/flows/os/cron_spec.rb +287 -0
- data/spec/utilities/flows/os/launchd_spec.rb +293 -0
- data/spec/utilities/flows/os/linux_spec.rb +149 -0
- data/spec/utilities/flows/os/systemd_spec.rb +329 -0
- data/spec/utilities/flows/os/task_scheduler_spec.rb +505 -0
- data/spec/utilities/flows/project_spec.rb +178 -0
- data/spec/utilities/flows/run_log_spec.rb +47 -0
- data/spec/utilities/flows/runner_spec.rb +313 -0
- data/spec/utilities/flows/schedule_spec.rb +233 -0
- data/spec/utilities/flows/schedule_store_spec.rb +137 -0
- data/spec/utilities/flows/scheduler_spec.rb +329 -0
- metadata +53 -2
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require_relative '../../../../lib/utilities/flows/os/cron'
|
|
6
|
+
|
|
7
|
+
# Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
|
|
8
|
+
# The fake runner and project folders sit next to the adapter under test
|
|
9
|
+
# rubocop:disable RSpec/MultipleMemoizedHelpers
|
|
10
|
+
RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
|
|
11
|
+
# Plays crontab: -l prints the current table (or fails), - stores stdin; never runs anything
|
|
12
|
+
let(:fake_runner_class) do
|
|
13
|
+
Class.new do
|
|
14
|
+
attr_reader :calls, :answers
|
|
15
|
+
attr_accessor :table
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@calls = []
|
|
19
|
+
@answers = {}
|
|
20
|
+
@table = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(*argv, stdin_data: nil)
|
|
24
|
+
@calls << [*argv, stdin_data].compact
|
|
25
|
+
verb = argv[1]
|
|
26
|
+
missing = verb == '-l' && table.nil?
|
|
27
|
+
stderr, status = answers.fetch(verb) { missing ? ['no crontab for raider', 1] : ['', 0] }
|
|
28
|
+
stdout = verb == '-l' ? table.to_s : ''
|
|
29
|
+
self.table = stdin_data if verb == '-' && status.zero?
|
|
30
|
+
RubyRaider::Flows::Os::CommandRunner::Result.new(stdout: stdout, stderr: stderr, exitstatus: status)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
let(:hostile) { %q('; rm -rf ~; ' </string> %h $(id) " \\ &) }
|
|
36
|
+
let(:tmp) { File.realpath(Dir.mktmpdir('flows-cron')) }
|
|
37
|
+
let(:project_dir) { File.join(tmp, 'plain') }
|
|
38
|
+
let(:runner) { fake_runner_class.new }
|
|
39
|
+
let(:cron) { described_class.new(runner: runner) }
|
|
40
|
+
let(:now) { Time.utc(2026, 11, 9, 10, 0, 0) }
|
|
41
|
+
|
|
42
|
+
def schedule(dir: project_dir, name: 'report', **options)
|
|
43
|
+
FileUtils.mkdir_p(dir)
|
|
44
|
+
project = RubyRaider::Flows::Project.new(File.realpath(dir))
|
|
45
|
+
RubyRaider::Flows::Schedule.build(project: project, name: name, every: 'day', at: '08:00', now: now, **options)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# What cron (cronie/vixie do_command.c) hands to /bin/sh: a backslash escapes the next character, an
|
|
49
|
+
# escaped % loses its backslash, any other escaped character keeps it, and an unescaped % would end the
|
|
50
|
+
# command (the rest becomes stdin). Strict: an unescaped % fails the spec.
|
|
51
|
+
def cron_command(text)
|
|
52
|
+
command = +''
|
|
53
|
+
escaped = false
|
|
54
|
+
text.each_char do |char|
|
|
55
|
+
if escaped
|
|
56
|
+
command.chop! if char == '%'
|
|
57
|
+
command << char
|
|
58
|
+
escaped = false
|
|
59
|
+
next
|
|
60
|
+
end
|
|
61
|
+
raise ArgumentError, "unescaped % in #{text.inspect}" if char == '%'
|
|
62
|
+
|
|
63
|
+
escaped = char == '\\'
|
|
64
|
+
command << char
|
|
65
|
+
end
|
|
66
|
+
command
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def fields_and_words(line)
|
|
70
|
+
fields = line.split(' ', 6)
|
|
71
|
+
[fields.first(5), Shellwords.split(cron_command(fields.last))]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def expected_words(item)
|
|
75
|
+
['cd', item.working_directory, '&&', '/usr/bin/env', *item.env.map { |key, value| "#{key}=#{value}" }, *item.argv,
|
|
76
|
+
'>>', item.log_path, '2>&1']
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def block(item) = "# raider-flow #{item.os_id}\n#{cron.line(item)}\n"
|
|
80
|
+
|
|
81
|
+
after { FileUtils.rm_rf(tmp) }
|
|
82
|
+
|
|
83
|
+
it 'is called cron in messages and says missed runs are skipped' do
|
|
84
|
+
expect([cron.label, cron.missed_runs_note]).to eq(['cron', '(cron: missed runs are skipped)'])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
describe '#line' do
|
|
88
|
+
it 'writes the exact line for a daily schedule' do
|
|
89
|
+
item = schedule
|
|
90
|
+
env = item.env.map { |key, value| Shellwords.escape("#{key}=#{value}") }.join(' ')
|
|
91
|
+
args = item.argv.map { |arg| Shellwords.escape(arg) }.join(' ')
|
|
92
|
+
|
|
93
|
+
expect(cron.line(item)).to eq("0 8 * * * cd #{Shellwords.escape(item.working_directory)} && /usr/bin/env #{env} #{args} " \
|
|
94
|
+
">> #{Shellwords.escape(item.log_path)} 2>&1")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'turns each rule into cron fields, Sunday 0' do
|
|
98
|
+
rules = [schedule(every: 'hour', at: ':15'), schedule(every: 'weekday', at: '18:05'),
|
|
99
|
+
schedule(every: 'week', on: 'sun', at: '07:30'), schedule(every: 'week', on: 'sat', at: '23:59')]
|
|
100
|
+
|
|
101
|
+
expect(rules.map { |item| fields_and_words(cron.line(item)).first.join(' ') })
|
|
102
|
+
.to eq(['15 * * * *', '5 18 * * 1-5', '30 7 * * 0', '59 23 * * 6'])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'keeps GEM_HOME and GEM_PATH in the environment when they were captured' do
|
|
106
|
+
allow(RubyRaider::Flows::Schedule).to receive(:captured_gem_env).and_return('GEM_HOME' => '/g/%home', 'GEM_PATH' => '/g/path')
|
|
107
|
+
|
|
108
|
+
expect(fields_and_words(cron.line(schedule)).last).to include('GEM_HOME=/g/%home', 'GEM_PATH=/g/path')
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'refuses a ruby path env would read as a variable' do
|
|
112
|
+
plain = schedule
|
|
113
|
+
item = RubyRaider::Flows::Schedule.from_h(plain.to_h.merge('ruby' => '/opt/a=b/ruby'), plain.project_root)
|
|
114
|
+
|
|
115
|
+
expect { cron.line(item) }.to raise_error(RubyRaider::Flows::Error, /cron can not run ruby from .*a=b/)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
describe 'with hostile values in an input and in the project path' do
|
|
119
|
+
let(:item) { schedule(dir: File.join(tmp, "p#{hostile}<", "string> #{hostile} 100%"), inputs: ["note=#{hostile} 50%"]) }
|
|
120
|
+
let(:text) { cron.line(item) }
|
|
121
|
+
|
|
122
|
+
it 'gives back the exact command words after cron and the shell read it' do
|
|
123
|
+
fields, words = fields_and_words(text)
|
|
124
|
+
|
|
125
|
+
expect([fields, words]).to eq([%w[0 8 * * *], expected_words(item)])
|
|
126
|
+
expect(words).to include("note=#{hostile} 50%")
|
|
127
|
+
expect(item.working_directory).to include('</string>', "'; rm -rf ~; '", '$(id)', '%h', '"', '\\', '&', ' ')
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it 'writes them only in escaped form' do
|
|
131
|
+
expect(text).to include('\\%h', '\\$\\(id\\)', "\\'\\;\\ rm")
|
|
132
|
+
expect(text).not_to include('$(id)', "'; rm", "\n")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
describe '#install' do
|
|
138
|
+
it 'starts a crontab when there is none' do
|
|
139
|
+
item = schedule
|
|
140
|
+
|
|
141
|
+
cron.install(item)
|
|
142
|
+
|
|
143
|
+
expect(runner.calls).to eq([%w[crontab -l], ['crontab', '-', block(item)]])
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it 'puts the block first and keeps every other byte, CRLF and a missing last newline included' do
|
|
147
|
+
item = schedule
|
|
148
|
+
other = "SHELL=/bin/fish\r\nMAILTO=\"\"\r\n# raider-flow dev.rubyraider.flow.0123456789.other\n1 2 * * * echo % \\%\n5 5 * * * last"
|
|
149
|
+
runner.table = other
|
|
150
|
+
|
|
151
|
+
cron.install(item)
|
|
152
|
+
|
|
153
|
+
expect(runner.table).to eq(block(item) + other)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'replaces its own block and nothing else' do
|
|
157
|
+
item = schedule
|
|
158
|
+
daily = block(item)
|
|
159
|
+
runner.table = "1 * * * * a\n#{daily}2 * * * * b\n"
|
|
160
|
+
|
|
161
|
+
cron.install(schedule(every: 'hour', at: ':10'))
|
|
162
|
+
|
|
163
|
+
expect(runner.table).to eq("#{block(schedule(every: 'hour', at: ':10'))}1 * * * * a\n2 * * * * b\n")
|
|
164
|
+
expect(runner.table).not_to include(daily)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it 'keeps another flow block' do
|
|
168
|
+
other = schedule(name: 'nightly')
|
|
169
|
+
runner.table = block(other)
|
|
170
|
+
|
|
171
|
+
cron.install(schedule)
|
|
172
|
+
|
|
173
|
+
expect(runner.table).to eq(block(schedule) + block(other))
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it 'refuses a line vixie cron would cut short and writes nothing' do
|
|
177
|
+
item = schedule(inputs: ["note=#{'x' * 900}"])
|
|
178
|
+
|
|
179
|
+
expect { cron.install(item) }.to raise_error(RubyRaider::Flows::Error, /crontab line is \d{4} bytes, over 990/)
|
|
180
|
+
expect(runner.calls).to be_empty
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it 'raises with the scheduler error when crontab -l fails for another reason' do
|
|
184
|
+
runner.answers['-l'] = ["crontab: permission denied\e[2J", 1]
|
|
185
|
+
|
|
186
|
+
expect { cron.install(schedule) }.to raise_error(RubyRaider::Flows::Error, /cron could not read .*permission denied\?\[2J/)
|
|
187
|
+
expect(runner.calls).to eq([%w[crontab -l]])
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it 'raises with the scheduler error when crontab - fails' do
|
|
191
|
+
runner.table = "1 * * * * a\n"
|
|
192
|
+
runner.answers['-'] = ['crontab: errors in crontab file', 1]
|
|
193
|
+
|
|
194
|
+
expect { cron.install(schedule) }.to raise_error(RubyRaider::Flows::Error, /cron could not save .*errors in crontab file/)
|
|
195
|
+
expect(runner.table).to eq("1 * * * * a\n")
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
describe '#remove' do
|
|
200
|
+
it 'removes its block and gives back the crontab byte for byte' do
|
|
201
|
+
other = "A=1\r\n1 * * * * a\n2 * * * * b"
|
|
202
|
+
runner.table = other
|
|
203
|
+
cron.install(schedule)
|
|
204
|
+
|
|
205
|
+
expect(cron.remove(schedule.os_id)).to be(true)
|
|
206
|
+
expect(runner.table).to eq(other)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
it 'removes every copy of its block' do
|
|
210
|
+
item = schedule
|
|
211
|
+
runner.table = "#{block(item)}1 * * * * a\n#{block(item)}"
|
|
212
|
+
|
|
213
|
+
expect(cron.remove(item.os_id)).to be(true)
|
|
214
|
+
expect(runner.table).to eq("1 * * * * a\n")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it 'keeps an unrelated line that follows an orphaned tag' do
|
|
218
|
+
item = schedule
|
|
219
|
+
other = schedule(name: 'nightly')
|
|
220
|
+
runner.table = "# raider-flow #{item.os_id}\n#{cron.line(other)}\n1 * * * * a\n"
|
|
221
|
+
|
|
222
|
+
expect(cron.remove(item.os_id)).to be(true)
|
|
223
|
+
expect(runner.table).to eq("#{cron.line(other)}\n1 * * * * a\n")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
it 'keeps a line under its tag that is not a schedule run' do
|
|
227
|
+
item = schedule
|
|
228
|
+
manual = cron.line(item).sub(' --trigger schedule', ' --trigger manual')
|
|
229
|
+
runner.table = "# raider-flow #{item.os_id}\n#{manual}\n"
|
|
230
|
+
|
|
231
|
+
expect(cron.remove(item.os_id)).to be(true)
|
|
232
|
+
expect(runner.table).to eq("#{manual}\n")
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
it 'removes a tag line left without its line' do
|
|
236
|
+
item = schedule
|
|
237
|
+
runner.table = "1 * * * * a\n# raider-flow #{item.os_id}"
|
|
238
|
+
|
|
239
|
+
expect(cron.remove(item.os_id)).to be(true)
|
|
240
|
+
expect(runner.table).to eq("1 * * * * a\n")
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
it 'is false and writes nothing when its block is not there' do
|
|
244
|
+
runner.table = "1 * * * * a\n# raider-flow #{schedule.os_id}x\n"
|
|
245
|
+
|
|
246
|
+
expect(cron.remove(schedule.os_id)).to be(false)
|
|
247
|
+
expect(runner.calls).to eq([%w[crontab -l]])
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
it 'is false when there is no crontab' do
|
|
251
|
+
expect(cron.remove(schedule.os_id)).to be(false)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
it 'refuses an os_id that is not a flow schedule id' do
|
|
255
|
+
expect { cron.remove("x\n* * * * * evil") }.to raise_error(RubyRaider::Flows::Error, /Invalid os_id/)
|
|
256
|
+
expect(runner.calls).to be_empty
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
describe '#usable?' do
|
|
261
|
+
it 'is true when crontab -l answers, even with no crontab' do
|
|
262
|
+
expect(cron.usable?).to be(true)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
it 'is false when crontab -l fails for another reason' do
|
|
266
|
+
runner.answers['-l'] = ['crontab: not found', 127]
|
|
267
|
+
|
|
268
|
+
expect(cron.usable?).to be(false)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
describe '#present?' do
|
|
273
|
+
it 'is true when the tag line is there' do
|
|
274
|
+
item = schedule
|
|
275
|
+
runner.table = block(item)
|
|
276
|
+
|
|
277
|
+
expect(cron.present?(item.os_id)).to be(true)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
it 'is false when there is no crontab or no tag line' do
|
|
281
|
+
expect(cron.present?(schedule.os_id)).to be(false)
|
|
282
|
+
runner.table = "1 * * * * a\n"
|
|
283
|
+
expect(cron.present?(schedule.os_id)).to be(false)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
# rubocop:enable RSpec/MultipleMemoizedHelpers
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
require 'rexml/document'
|
|
5
|
+
require_relative '../../../../lib/utilities/flows/os/launchd'
|
|
6
|
+
|
|
7
|
+
# Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
|
|
8
|
+
# The fake runner, temp home and project folders sit next to the adapter under test
|
|
9
|
+
# rubocop:disable RSpec/MultipleMemoizedHelpers
|
|
10
|
+
RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePath
|
|
11
|
+
# Records every command and answers from a table (success unless told otherwise); never runs anything
|
|
12
|
+
let(:fake_runner_class) do
|
|
13
|
+
Class.new do
|
|
14
|
+
attr_reader :calls, :answers, :stdin
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@calls = []
|
|
18
|
+
@answers = {}
|
|
19
|
+
@stdin = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(*argv, stdin_data: nil)
|
|
23
|
+
@calls << argv
|
|
24
|
+
@stdin << stdin_data
|
|
25
|
+
@seen&.call(argv)
|
|
26
|
+
stderr, status = answers.fetch(argv[1], ['', 0])
|
|
27
|
+
RubyRaider::Flows::Os::CommandRunner::Result.new(stdout: '', stderr: stderr, exitstatus: status)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def on_call(&block) = @seen = block
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
let(:hostile) { %q('; rm -rf ~; ' </string> %h $(id) " \\ &) }
|
|
35
|
+
let(:tmp) { File.realpath(Dir.mktmpdir('flows-launchd')) }
|
|
36
|
+
let(:home) { File.join(tmp, 'home') }
|
|
37
|
+
let(:project_dir) { File.join(tmp, 'plain') }
|
|
38
|
+
let(:runner) { fake_runner_class.new }
|
|
39
|
+
let(:launchd) { described_class.new(runner: runner, home: home, uid: 501) }
|
|
40
|
+
let(:now) { Time.utc(2026, 11, 9, 10, 0, 0) }
|
|
41
|
+
let(:agents) { File.join(home, 'Library', 'LaunchAgents') }
|
|
42
|
+
|
|
43
|
+
def schedule(dir: project_dir, **options)
|
|
44
|
+
FileUtils.mkdir_p(dir)
|
|
45
|
+
project = RubyRaider::Flows::Project.new(File.realpath(dir))
|
|
46
|
+
RubyRaider::Flows::Schedule.build(project: project, name: 'report', every: 'day', at: '08:00', now: now, **options)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# plist XML back to Ruby: dict -> Hash, array -> Array, string -> String, integer -> Integer
|
|
50
|
+
def plist_value(element)
|
|
51
|
+
case element.name
|
|
52
|
+
when 'dict'
|
|
53
|
+
element.elements.to_a.each_slice(2).to_h { |key, value| [key.text, plist_value(value)] }
|
|
54
|
+
when 'array' then element.elements.map { |child| plist_value(child) }
|
|
55
|
+
when 'integer' then Integer(element.text)
|
|
56
|
+
else element.text.to_s
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def parsed(text)
|
|
61
|
+
document = REXML::Document.new(text)
|
|
62
|
+
expect(document.root.name).to eq('plist')
|
|
63
|
+
plist_value(document.root.elements.first)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# What a daily 08:00 schedule must produce, byte for byte
|
|
67
|
+
def daily_plist(item)
|
|
68
|
+
env = item.env.map { |key, value| "\t\t<key>#{key}</key>\n\t\t<string>#{value}</string>\n" }.join
|
|
69
|
+
args = item.argv.map { |arg| "\t\t<string>#{arg}</string>\n" }.join
|
|
70
|
+
|
|
71
|
+
<<~PLIST
|
|
72
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
73
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
74
|
+
<plist version="1.0">
|
|
75
|
+
<dict>
|
|
76
|
+
\t<key>Label</key>
|
|
77
|
+
\t<string>#{item.os_id}</string>
|
|
78
|
+
\t<key>ProgramArguments</key>
|
|
79
|
+
\t<array>
|
|
80
|
+
#{args.chomp}
|
|
81
|
+
\t</array>
|
|
82
|
+
\t<key>WorkingDirectory</key>
|
|
83
|
+
\t<string>#{item.working_directory}</string>
|
|
84
|
+
\t<key>StandardOutPath</key>
|
|
85
|
+
\t<string>#{item.log_path}</string>
|
|
86
|
+
\t<key>StandardErrorPath</key>
|
|
87
|
+
\t<string>#{item.log_path}</string>
|
|
88
|
+
\t<key>EnvironmentVariables</key>
|
|
89
|
+
\t<dict>
|
|
90
|
+
#{env.chomp}
|
|
91
|
+
\t</dict>
|
|
92
|
+
\t<key>StartCalendarInterval</key>
|
|
93
|
+
\t<dict>
|
|
94
|
+
\t\t<key>Hour</key>
|
|
95
|
+
\t\t<integer>8</integer>
|
|
96
|
+
\t\t<key>Minute</key>
|
|
97
|
+
\t\t<integer>0</integer>
|
|
98
|
+
\t</dict>
|
|
99
|
+
</dict>
|
|
100
|
+
</plist>
|
|
101
|
+
PLIST
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def plist_path(item) = File.join(agents, "#{item.os_id}.plist")
|
|
105
|
+
|
|
106
|
+
after { FileUtils.rm_rf(tmp) }
|
|
107
|
+
|
|
108
|
+
it 'is called launchd in messages' do
|
|
109
|
+
expect(launchd.label).to eq('launchd')
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
describe '#plist' do
|
|
113
|
+
it 'writes the exact plist for a daily schedule' do
|
|
114
|
+
item = schedule
|
|
115
|
+
expect(launchd.plist(item)).to eq(daily_plist(item))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it 'round-trips every field and never sets RunAtLoad' do
|
|
119
|
+
item = schedule(retries: 2, inputs: %w[env=staging])
|
|
120
|
+
|
|
121
|
+
expect(parsed(launchd.plist(item))).to eq(
|
|
122
|
+
'Label' => item.os_id, 'ProgramArguments' => item.argv, 'WorkingDirectory' => item.working_directory,
|
|
123
|
+
'StandardOutPath' => item.log_path, 'StandardErrorPath' => item.log_path, 'EnvironmentVariables' => item.env,
|
|
124
|
+
'StartCalendarInterval' => { 'Hour' => 8, 'Minute' => 0 }
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it 'keeps GEM_HOME and GEM_PATH in the environment when they were captured' do
|
|
129
|
+
allow(RubyRaider::Flows::Schedule).to receive(:captured_gem_env).and_return('GEM_HOME' => '/g/home', 'GEM_PATH' => '/g/path')
|
|
130
|
+
|
|
131
|
+
env = parsed(launchd.plist(schedule))['EnvironmentVariables']
|
|
132
|
+
|
|
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
|
+
end
|
|
135
|
+
|
|
136
|
+
it 'runs every hour at its minute' do
|
|
137
|
+
expect(parsed(launchd.plist(schedule(every: 'hour', at: ':15')))['StartCalendarInterval']).to eq('Minute' => 15)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it 'runs every weekday as five dicts, Monday 1 to Friday 5' do
|
|
141
|
+
interval = parsed(launchd.plist(schedule(every: 'weekday', at: '18:05')))['StartCalendarInterval']
|
|
142
|
+
|
|
143
|
+
expect(interval).to eq((1..5).map { |day| { 'Weekday' => day, 'Hour' => 18, 'Minute' => 5 } })
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it 'runs every week on its day, Sunday 0 and Saturday 6' do
|
|
147
|
+
days = %w[sun mon sat].map { |on| parsed(launchd.plist(schedule(every: 'week', on: on)))['StartCalendarInterval'] }
|
|
148
|
+
|
|
149
|
+
expect(days).to eq([0, 1, 6].map { |day| { 'Weekday' => day, 'Hour' => 8, 'Minute' => 0 } })
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
describe 'with hostile values in an input and in the project path' do
|
|
153
|
+
let(:item) { schedule(dir: File.join(tmp, "p#{hostile}<", "string> #{hostile}"), inputs: ["note=#{hostile}"]) }
|
|
154
|
+
let(:text) { launchd.plist(item) }
|
|
155
|
+
|
|
156
|
+
it 'round-trips them exactly' do
|
|
157
|
+
data = parsed(text)
|
|
158
|
+
|
|
159
|
+
expect([data['ProgramArguments'], data['WorkingDirectory'], data['StandardOutPath']]).to eq([item.argv, item.working_directory, item.log_path])
|
|
160
|
+
expect(data['ProgramArguments'].last).to eq("note=#{hostile}")
|
|
161
|
+
expect(item.working_directory).to include('</string>', "'; rm -rf ~; '", '$(id)', '%h', '"', '\\', '&')
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
it 'writes them only in escaped form' do
|
|
165
|
+
expect(text).not_to include('</string> ', "'; rm", '" \\')
|
|
166
|
+
expect(text.scan(/&(?!amp;|lt;|gt;|quot;|apos;)/)).to be_empty
|
|
167
|
+
expect(text).to include('</string>', ''; rm -rf ~; '', '" \\ &', '$(id)', '%h')
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
describe '#install' do
|
|
173
|
+
it 'writes the plist 0644, lints it, then boots out and bootstraps it' do
|
|
174
|
+
item = schedule
|
|
175
|
+
path = plist_path(item)
|
|
176
|
+
linted = nil
|
|
177
|
+
runner.on_call { |argv| linted = File.read(argv[2]) if argv[1] == '-lint' }
|
|
178
|
+
|
|
179
|
+
launchd.install(item)
|
|
180
|
+
|
|
181
|
+
expect(runner.calls).to match([
|
|
182
|
+
['/usr/bin/plutil', '-lint', %r{\A#{Regexp.escape(agents)}/\..+\.tmp\z}],
|
|
183
|
+
['/bin/launchctl', 'bootout', "gui/501/#{item.os_id}"],
|
|
184
|
+
['/bin/launchctl', 'bootstrap', 'gui/501', path]
|
|
185
|
+
])
|
|
186
|
+
expect([File.read(path), linted, format('%o', File.stat(path).mode & 0o777)]).to eq([launchd.plist(item), launchd.plist(item), '644'])
|
|
187
|
+
expect(Dir.children(agents)).to eq([File.basename(path)])
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it 'replaces an existing entry and ignores a bootout that finds nothing' do
|
|
191
|
+
item = schedule
|
|
192
|
+
FileUtils.mkdir_p(agents)
|
|
193
|
+
File.write(plist_path(item), 'old')
|
|
194
|
+
runner.answers['bootout'] = ['Boot-out failed: 3: No such process', 3]
|
|
195
|
+
|
|
196
|
+
launchd.install(item)
|
|
197
|
+
|
|
198
|
+
expect(runner.calls.map { |argv| argv[1] }).to eq(%w[-lint bootout bootstrap])
|
|
199
|
+
expect(File.read(plist_path(item))).to eq(launchd.plist(item))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
it 'removes the plist and raises with the scheduler error when bootstrap fails' do
|
|
203
|
+
item = schedule
|
|
204
|
+
runner.answers['bootstrap'] = ["Bootstrap failed: 5: Input/output error\e[2J\n", 5]
|
|
205
|
+
|
|
206
|
+
expect { launchd.install(item) }.to raise_error(RubyRaider::Flows::Error, %r{launchd.*Bootstrap failed: 5: Input/output error\?\[2J})
|
|
207
|
+
expect(Dir.children(agents)).to be_empty
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
it 'says so when the plist a failed bootstrap left can not be deleted' do
|
|
211
|
+
item = schedule
|
|
212
|
+
runner.answers['bootstrap'] = ['Bootstrap failed: 5', 5]
|
|
213
|
+
allow(File).to receive(:unlink).and_call_original
|
|
214
|
+
allow(File).to receive(:unlink).with(plist_path(item)).and_raise(Errno::EACCES)
|
|
215
|
+
|
|
216
|
+
expect { launchd.install(item) }.to raise_error(RubyRaider::Flows::Error, /Bootstrap failed: 5; also: Could not remove .*Permission denied/)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it 'raises a flow error and runs nothing when it can not write the plist' do
|
|
220
|
+
FileUtils.mkdir_p(File.dirname(agents))
|
|
221
|
+
File.write(agents, 'not a folder')
|
|
222
|
+
|
|
223
|
+
expect { launchd.install(schedule) }.to raise_error(RubyRaider::Flows::Error, /Could not write .*LaunchAgents/)
|
|
224
|
+
expect([runner.calls, File.read(agents)]).to eq([[], 'not a folder'])
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
it 'leaves the entry alone and raises when the plist does not lint' do
|
|
228
|
+
item = schedule
|
|
229
|
+
FileUtils.mkdir_p(agents)
|
|
230
|
+
File.write(plist_path(item), 'old')
|
|
231
|
+
runner.answers['-lint'] = ['bad plist', 1]
|
|
232
|
+
|
|
233
|
+
expect { launchd.install(item) }.to raise_error(RubyRaider::Flows::Error, /bad plist/)
|
|
234
|
+
expect([runner.calls.map { |argv| argv[1] }, Dir.children(agents), File.read(plist_path(item))])
|
|
235
|
+
.to eq([['-lint'], [File.basename(plist_path(item))], 'old'])
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
describe '#remove' do
|
|
240
|
+
it 'boots out and deletes the plist' do
|
|
241
|
+
item = schedule
|
|
242
|
+
FileUtils.mkdir_p(agents)
|
|
243
|
+
File.write(plist_path(item), 'x')
|
|
244
|
+
|
|
245
|
+
expect(launchd.remove(item.os_id)).to be(true)
|
|
246
|
+
expect([runner.calls, File.exist?(plist_path(item))]).to eq([[['/bin/launchctl', 'bootout', "gui/501/#{item.os_id}"]], false])
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it 'is true when only the service was loaded' do
|
|
250
|
+
expect(launchd.remove(schedule.os_id)).to be(true)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
it 'is false when it was already gone' do
|
|
254
|
+
runner.answers['bootout'] = ['Boot-out failed: 3: No such process', 3]
|
|
255
|
+
|
|
256
|
+
expect(launchd.remove(schedule.os_id)).to be(false)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
it 'raises when the plist can not be deleted' do
|
|
260
|
+
item = schedule
|
|
261
|
+
allow(File).to receive(:unlink).and_raise(Errno::EACCES)
|
|
262
|
+
|
|
263
|
+
expect { launchd.remove(item.os_id) }.to raise_error(RubyRaider::Flows::Error, /Could not remove .*Permission denied/)
|
|
264
|
+
expect(runner.calls).to eq([['/bin/launchctl', 'bootout', "gui/501/#{item.os_id}"]])
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
it 'refuses an os_id that is not a flow schedule id' do
|
|
268
|
+
expect { launchd.remove('../../evil') }.to raise_error(RubyRaider::Flows::Error, /Invalid os_id/)
|
|
269
|
+
expect(runner.calls).to be_empty
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
describe '#present?' do
|
|
274
|
+
it 'asks launchctl print' do
|
|
275
|
+
item = schedule
|
|
276
|
+
|
|
277
|
+
expect(launchd.present?(item.os_id)).to be(true)
|
|
278
|
+
expect(runner.calls).to eq([['/bin/launchctl', 'print', "gui/501/#{item.os_id}"]])
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
it 'refuses an os_id that is not a flow schedule id and runs nothing' do
|
|
282
|
+
expect { launchd.present?('../../evil') }.to raise_error(RubyRaider::Flows::Error, /Invalid os_id/)
|
|
283
|
+
expect(runner.calls).to be_empty
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
it 'is false when launchctl print fails' do
|
|
287
|
+
runner.answers['print'] = ['Could not find service', 113]
|
|
288
|
+
|
|
289
|
+
expect(launchd.present?(schedule.os_id)).to be(false)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
# rubocop:enable RSpec/MultipleMemoizedHelpers
|