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,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
require_relative '../../../../lib/utilities/flows/os/linux'
|
|
5
|
+
|
|
6
|
+
# Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
|
|
7
|
+
# rubocop:disable RSpec/MultipleMemoizedHelpers
|
|
8
|
+
RSpec.describe RubyRaider::Flows::Os::Linux do # rubocop:disable RSpec/FilePath
|
|
9
|
+
# Plays systemctl --user and crontab: answers keyed by "systemctl verb" or "crontab -l"; never runs anything
|
|
10
|
+
let(:fake_runner_class) do
|
|
11
|
+
Class.new do
|
|
12
|
+
attr_reader :calls, :answers
|
|
13
|
+
attr_accessor :table
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@calls = []
|
|
17
|
+
@answers = {}
|
|
18
|
+
@table = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(*argv, stdin_data: nil)
|
|
22
|
+
@calls << argv
|
|
23
|
+
key = argv.first == 'crontab' ? "crontab #{argv[1]}" : "systemctl #{argv[2]}"
|
|
24
|
+
missing = key == 'crontab -l' && table.nil?
|
|
25
|
+
stderr, status = answers.fetch(key) { missing ? ['no crontab for raider', 1] : ['', 0] }
|
|
26
|
+
self.table = stdin_data if key == 'crontab -' && status.zero?
|
|
27
|
+
RubyRaider::Flows::Os::CommandRunner::Result.new(stdout: key == 'crontab -l' ? table.to_s : '', stderr: stderr, exitstatus: status)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
let(:tmp) { File.realpath(Dir.mktmpdir('flows-linux')) }
|
|
33
|
+
let(:runner) { fake_runner_class.new }
|
|
34
|
+
let(:item) do
|
|
35
|
+
project = RubyRaider::Flows::Project.new(tmp)
|
|
36
|
+
RubyRaider::Flows::Schedule.build(project: project, name: 'report', every: 'day', at: '08:00', now: Time.utc(2026, 11, 9))
|
|
37
|
+
end
|
|
38
|
+
let(:cron_block) { "# raider-flow #{item.os_id}\n#{RubyRaider::Flows::Os::Cron.new.line(item)}\n" }
|
|
39
|
+
let(:no_bus) { ['Failed to connect to bus', 1] }
|
|
40
|
+
|
|
41
|
+
# The systemd units go to a temp home, never the real one
|
|
42
|
+
def pick = described_class.new(runner: runner, systemd: RubyRaider::Flows::Os::Systemd.new(runner: runner, home: tmp, config_home: nil))
|
|
43
|
+
|
|
44
|
+
after { FileUtils.rm_rf(tmp) }
|
|
45
|
+
|
|
46
|
+
it 'uses systemd when the user manager answers' do
|
|
47
|
+
linux = pick
|
|
48
|
+
|
|
49
|
+
expect([linux.label, linux.missed_runs_note, described_class.pick(runner).label]).to eq(['systemd', nil, 'systemd'])
|
|
50
|
+
expect(runner.calls.uniq).to eq([%w[systemctl --user show-environment]])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'falls back to cron when it does not, or systemctl is missing' do
|
|
54
|
+
[no_bus, ['Could not run systemctl', RubyRaider::Flows::Os::CommandRunner::MISSING]].each do |answer|
|
|
55
|
+
runner.answers['systemctl show-environment'] = answer
|
|
56
|
+
|
|
57
|
+
expect([pick.label, pick.missed_runs_note]).to eq(['cron', '(cron: missed runs are skipped)'])
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'installs with the primary scheduler only' do
|
|
62
|
+
runner.answers['systemctl show-environment'] = no_bus
|
|
63
|
+
|
|
64
|
+
pick.install(item)
|
|
65
|
+
|
|
66
|
+
expect([runner.table, runner.calls.map(&:first).uniq]).to eq([cron_block, %w[systemctl crontab]])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
describe '#remove' do
|
|
70
|
+
it 'also removes a cron entry when systemd is the primary' do
|
|
71
|
+
runner.table = "#{cron_block}1 * * * * a\n"
|
|
72
|
+
|
|
73
|
+
expect(pick.remove(item.os_id)).to be(true)
|
|
74
|
+
expect(runner.table).to eq("1 * * * * a\n")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'is false when neither has it' do
|
|
78
|
+
expect(pick.remove(item.os_id)).to be(false)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'skips cron when crontab -l does not work' do
|
|
82
|
+
runner.answers['crontab -l'] = ['crontab: not found', 127]
|
|
83
|
+
|
|
84
|
+
expect(pick.remove(item.os_id)).to be(false)
|
|
85
|
+
expect(runner.calls).not_to include(%w[crontab -])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'never asks systemd when cron is the primary, since systemd did not answer' do
|
|
89
|
+
runner.answers['systemctl show-environment'] = no_bus
|
|
90
|
+
runner.table = cron_block
|
|
91
|
+
|
|
92
|
+
expect(pick.remove(item.os_id)).to be(true)
|
|
93
|
+
expect(runner.calls.select { |argv| argv.first == 'systemctl' }).to eq([%w[systemctl --user show-environment]])
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
describe 'with a systemd timer, from a session where systemd does not answer' do
|
|
98
|
+
let(:units) { File.join(tmp, '.config', 'systemd', 'user') }
|
|
99
|
+
|
|
100
|
+
before do
|
|
101
|
+
runner.answers['systemctl show-environment'] = no_bus
|
|
102
|
+
FileUtils.mkdir_p(units)
|
|
103
|
+
File.write(File.join(units, "#{item.os_id}.timer"), 'x')
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it 'is present' do
|
|
107
|
+
expect(pick.present?(item.os_id)).to be(true)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it 'removes the cron block, then raises so the schedule is kept, and leaves the unit' do
|
|
111
|
+
runner.table = "#{cron_block}1 * * * * a\n"
|
|
112
|
+
|
|
113
|
+
expect { pick.remove(item.os_id) }
|
|
114
|
+
.to raise_error(RubyRaider::Flows::Error, 'This schedule is a systemd timer; run raider flow unschedule report ' \
|
|
115
|
+
'from a session where systemctl --user works')
|
|
116
|
+
expect([runner.table, File.exist?(File.join(units, "#{item.os_id}.timer"))]).to eq(["1 * * * * a\n", true])
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it 'does not look for systemd unit files when systemd is the primary' do
|
|
121
|
+
FileUtils.mkdir_p(File.join(tmp, '.config', 'systemd', 'user'))
|
|
122
|
+
File.write(File.join(tmp, '.config', 'systemd', 'user', "#{item.os_id}.timer"), 'x')
|
|
123
|
+
|
|
124
|
+
expect(pick.remove(item.os_id)).to be(true)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
describe '#present?' do
|
|
128
|
+
it 'is true when only the other scheduler has it' do
|
|
129
|
+
runner.answers['systemctl is-enabled'] = ['disabled', 1]
|
|
130
|
+
runner.table = cron_block
|
|
131
|
+
|
|
132
|
+
expect(pick.present?(item.os_id)).to be(true)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it 'is false when neither has it' do
|
|
136
|
+
runner.answers['systemctl is-enabled'] = ['disabled', 1]
|
|
137
|
+
|
|
138
|
+
expect(pick.present?(item.os_id)).to be(false)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it 'counts a failing other scheduler as not there' do
|
|
142
|
+
runner.answers['systemctl is-enabled'] = ['disabled', 1]
|
|
143
|
+
runner.answers['crontab -l'] = ['crontab: permission denied', 1]
|
|
144
|
+
|
|
145
|
+
expect(pick.present?(item.os_id)).to be(false)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
# rubocop:enable RSpec/MultipleMemoizedHelpers
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
require_relative '../../../../lib/utilities/flows/os/systemd'
|
|
5
|
+
|
|
6
|
+
# Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
|
|
7
|
+
# The fake runner, temp home and project folders sit next to the adapter under test
|
|
8
|
+
# rubocop:disable RSpec/MultipleMemoizedHelpers
|
|
9
|
+
RSpec.describe RubyRaider::Flows::Os::Systemd do # rubocop:disable RSpec/FilePath
|
|
10
|
+
# Records every command and answers from a table keyed by the systemctl verb; never runs anything
|
|
11
|
+
let(:fake_runner_class) do
|
|
12
|
+
Class.new do
|
|
13
|
+
attr_reader :calls, :answers
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@calls = []
|
|
17
|
+
@answers = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(*argv, stdin_data: nil)
|
|
21
|
+
@calls << argv
|
|
22
|
+
@seen&.call(argv, stdin_data)
|
|
23
|
+
stderr, status = answers.fetch(argv[2], ['', 0])
|
|
24
|
+
RubyRaider::Flows::Os::CommandRunner::Result.new(stdout: '', stderr: stderr, exitstatus: status)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def on_call(&block) = @seen = block
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
let(:hostile) { %q('; rm -rf ~; ' </string> %h $(id) " \\ &) }
|
|
32
|
+
let(:tmp) { File.realpath(Dir.mktmpdir('flows-systemd')) }
|
|
33
|
+
let(:home) { File.join(tmp, 'home') }
|
|
34
|
+
let(:project_dir) { File.join(tmp, 'plain') }
|
|
35
|
+
let(:runner) { fake_runner_class.new }
|
|
36
|
+
let(:systemd) { described_class.new(runner: runner, home: home, config_home: nil) }
|
|
37
|
+
let(:now) { Time.utc(2026, 11, 9, 10, 0, 0) }
|
|
38
|
+
let(:units) { File.join(home, '.config', 'systemd', 'user') }
|
|
39
|
+
|
|
40
|
+
def schedule(dir: project_dir, **options)
|
|
41
|
+
FileUtils.mkdir_p(dir)
|
|
42
|
+
project = RubyRaider::Flows::Project.new(File.realpath(dir))
|
|
43
|
+
RubyRaider::Flows::Schedule.build(project: project, name: 'report', every: 'day', at: '08:00', now: now, **options)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# systemd's specifier rule for a raw value: %% is %, any other % would be a specifier
|
|
47
|
+
def unspecify(text)
|
|
48
|
+
text.gsub(/%(.?)/m) do
|
|
49
|
+
raise ArgumentError, "specifier %#{Regexp.last_match(1)} in #{text.inspect}" unless Regexp.last_match(1) == '%'
|
|
50
|
+
|
|
51
|
+
'%'
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Splits a value the way systemd reads ExecStart= (dollars: true) or Environment= (dollars: false): words
|
|
56
|
+
# separated by spaces, each in double quotes with \\ and \" as the only C escapes, %% for %, and in ExecStart
|
|
57
|
+
# $$ for $. Strict: anything else raises, so a value that would be expanded or misread fails the spec.
|
|
58
|
+
# :reek:TooManyStatements
|
|
59
|
+
def systemd_words(text, dollars:)
|
|
60
|
+
words = []
|
|
61
|
+
rest = text.dup
|
|
62
|
+
until rest.empty?
|
|
63
|
+
raise ArgumentError, "unquoted word at #{rest.inspect}" unless rest.start_with?('"')
|
|
64
|
+
|
|
65
|
+
word = +''
|
|
66
|
+
index = 1
|
|
67
|
+
loop do
|
|
68
|
+
char = rest[index] or raise ArgumentError, 'unterminated quote'
|
|
69
|
+
index += 1
|
|
70
|
+
break if char == '"'
|
|
71
|
+
|
|
72
|
+
if char == '\\'
|
|
73
|
+
escaped = rest[index]
|
|
74
|
+
raise ArgumentError, "C escape \\#{escaped}" unless ['\\', '"'].include?(escaped)
|
|
75
|
+
|
|
76
|
+
word << escaped
|
|
77
|
+
index += 1
|
|
78
|
+
elsif char == '%' || (dollars && char == '$')
|
|
79
|
+
raise ArgumentError, "lone #{char}" unless rest[index] == char
|
|
80
|
+
|
|
81
|
+
word << char
|
|
82
|
+
index += 1
|
|
83
|
+
else
|
|
84
|
+
word << char
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
words << word
|
|
88
|
+
rest = rest[index..].sub(/\A /, '')
|
|
89
|
+
end
|
|
90
|
+
words
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def value_of(text, key) = text.lines.map(&:chomp).select { |line| line.start_with?("#{key}=") }.map { |line| line.delete_prefix("#{key}=") }
|
|
94
|
+
|
|
95
|
+
def service_path(item) = File.join(units, "#{item.os_id}.service")
|
|
96
|
+
|
|
97
|
+
def timer_path(item) = File.join(units, "#{item.os_id}.timer")
|
|
98
|
+
|
|
99
|
+
after { FileUtils.rm_rf(tmp) }
|
|
100
|
+
|
|
101
|
+
it 'is called systemd in messages and has no missed runs note' do
|
|
102
|
+
expect([systemd.label, systemd.missed_runs_note]).to eq(['systemd', nil])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
describe '#service' do
|
|
106
|
+
it 'writes the exact unit for a daily schedule' do
|
|
107
|
+
item = schedule
|
|
108
|
+
exec = item.argv.map { |arg| %("#{arg}") }.join(' ')
|
|
109
|
+
env = item.env.map { |key, value| %(Environment="#{key}=#{value}"\n) }.join
|
|
110
|
+
|
|
111
|
+
expect(systemd.service(item)).to eq(<<~UNIT)
|
|
112
|
+
[Unit]
|
|
113
|
+
Description=raider flow report
|
|
114
|
+
|
|
115
|
+
[Service]
|
|
116
|
+
Type=oneshot
|
|
117
|
+
WorkingDirectory=#{item.working_directory}
|
|
118
|
+
#{env.chomp}
|
|
119
|
+
ExecStart=#{exec}
|
|
120
|
+
StandardOutput=append:#{item.log_path}
|
|
121
|
+
StandardError=append:#{item.log_path}
|
|
122
|
+
UNIT
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'keeps GEM_HOME and GEM_PATH in the environment when they were captured' do
|
|
126
|
+
allow(RubyRaider::Flows::Schedule).to receive(:captured_gem_env).and_return('GEM_HOME' => '/g/%h$x', 'GEM_PATH' => '/g/path')
|
|
127
|
+
|
|
128
|
+
lines = value_of(systemd.service(schedule), 'Environment').flat_map { |value| systemd_words(value, dollars: false) }
|
|
129
|
+
|
|
130
|
+
expect(lines.last(3)).to eq(['LANG=en_US.UTF-8', 'GEM_HOME=/g/%h$x', 'GEM_PATH=/g/path'])
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
describe 'with hostile values in an input and in the project path' do
|
|
134
|
+
let(:item) { schedule(dir: File.join(tmp, "p#{hostile}<", "string> #{hostile}"), inputs: ["note=#{hostile}"]) }
|
|
135
|
+
let(:text) { systemd.service(item) }
|
|
136
|
+
|
|
137
|
+
it 'gives back the exact argv from ExecStart' do
|
|
138
|
+
argv = systemd_words(value_of(text, 'ExecStart').first, dollars: true)
|
|
139
|
+
|
|
140
|
+
expect(argv).to eq(item.argv)
|
|
141
|
+
expect(argv.last).to eq("note=#{hostile}")
|
|
142
|
+
expect(item.working_directory).to include('</string>', "'; rm -rf ~; '", '$(id)', '%h', '"', '\\', '&', ' ')
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it 'gives back the exact paths and environment' do
|
|
146
|
+
env = value_of(text, 'Environment').flat_map { |value| systemd_words(value, dollars: false) }
|
|
147
|
+
|
|
148
|
+
expect([unspecify(value_of(text, 'WorkingDirectory').first), value_of(text, 'StandardOutput').map { |value| unspecify(value) }])
|
|
149
|
+
.to eq([item.working_directory, ["append:#{item.log_path}"]])
|
|
150
|
+
expect(env).to eq(item.env.map { |key, value| "#{key}=#{value}" })
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it 'writes them only in escaped form' do
|
|
154
|
+
expect(text).to include('%%h', '$$(id)', '\\" \\\\ &')
|
|
155
|
+
expect(text.scan(/(?<!%)%(?!%)/)).to be_empty
|
|
156
|
+
expect(text.lines.length).to eq(systemd.service(schedule).lines.length)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
describe '#timer' do
|
|
162
|
+
it 'writes the exact timer for a daily schedule' do
|
|
163
|
+
expect(systemd.timer(schedule)).to eq(<<~UNIT)
|
|
164
|
+
[Unit]
|
|
165
|
+
Description=raider flow report schedule
|
|
166
|
+
|
|
167
|
+
[Timer]
|
|
168
|
+
OnCalendar=*-*-* 08:00:00
|
|
169
|
+
Persistent=true
|
|
170
|
+
|
|
171
|
+
[Install]
|
|
172
|
+
WantedBy=timers.target
|
|
173
|
+
UNIT
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it 'turns each rule into OnCalendar' do
|
|
177
|
+
rules = [schedule(every: 'hour', at: ':15'), schedule(every: 'weekday', at: '18:05'), schedule(every: 'week', on: 'sun', at: '07:30')]
|
|
178
|
+
|
|
179
|
+
expect(rules.map { |item| value_of(systemd.timer(item), 'OnCalendar').first })
|
|
180
|
+
.to eq(['*-*-* *:15:00', 'Mon..Fri *-*-* 18:05:00', 'Sun *-*-* 07:30:00'])
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
describe '#install' do
|
|
185
|
+
it 'writes both units 0644, reloads, then enables the timer' do
|
|
186
|
+
item = schedule
|
|
187
|
+
|
|
188
|
+
systemd.install(item)
|
|
189
|
+
|
|
190
|
+
expect(runner.calls).to eq([%w[systemctl --user daemon-reload], ['systemctl', '--user', 'enable', '--now', "#{item.os_id}.timer"]])
|
|
191
|
+
expect([File.read(service_path(item)), File.read(timer_path(item))]).to eq([systemd.service(item), systemd.timer(item)])
|
|
192
|
+
expect([service_path(item), timer_path(item)].map { |path| format('%o', File.stat(path).mode & 0o777) }).to eq(%w[644 644])
|
|
193
|
+
expect(Dir.children(units).sort).to eq([File.basename(service_path(item)), File.basename(timer_path(item))])
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it 'uses an absolute XDG_CONFIG_HOME and ignores a relative one' do
|
|
197
|
+
item = schedule
|
|
198
|
+
config = File.join(tmp, 'xdg')
|
|
199
|
+
described_class.new(runner: runner, home: home, config_home: config).install(item)
|
|
200
|
+
described_class.new(runner: runner, home: home, config_home: 'relative').install(item)
|
|
201
|
+
|
|
202
|
+
expect([File.exist?(File.join(config, 'systemd', 'user', "#{item.os_id}.timer")), File.exist?(timer_path(item))]).to eq([true, true])
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
it 'replaces existing units' do
|
|
206
|
+
item = schedule
|
|
207
|
+
FileUtils.mkdir_p(units)
|
|
208
|
+
File.write(service_path(item), 'old')
|
|
209
|
+
File.write(timer_path(item), 'old')
|
|
210
|
+
|
|
211
|
+
systemd.install(item)
|
|
212
|
+
|
|
213
|
+
expect([File.read(service_path(item)), File.read(timer_path(item))]).to eq([systemd.service(item), systemd.timer(item)])
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
it 'removes both units, reloads and raises with the scheduler error when enable fails' do
|
|
217
|
+
item = schedule
|
|
218
|
+
runner.answers['enable'] = ["Failed to enable unit\e[2J\n", 1]
|
|
219
|
+
|
|
220
|
+
expect { systemd.install(item) }.to raise_error(RubyRaider::Flows::Error, /systemd could not enable .*Failed to enable unit\?\[2J/)
|
|
221
|
+
expect(runner.calls.map { |argv| argv[2] }).to eq(%w[daemon-reload enable disable daemon-reload])
|
|
222
|
+
expect(runner.calls[2]).to eq(['systemctl', '--user', 'disable', "#{item.os_id}.timer"])
|
|
223
|
+
expect(Dir.children(units)).to be_empty
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
it 'removes both units when the first reload fails' do
|
|
227
|
+
item = schedule
|
|
228
|
+
runner.answers['daemon-reload'] = ['Failed to connect to bus', 1]
|
|
229
|
+
|
|
230
|
+
expect { systemd.install(item) }.to raise_error(RubyRaider::Flows::Error, /Failed to connect to bus/)
|
|
231
|
+
expect(Dir.children(units)).to be_empty
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
it 'refuses a project path systemd can not keep, before writing anything' do
|
|
235
|
+
['trailing ', 'tab\\'].each do |name|
|
|
236
|
+
item = schedule(dir: File.join(tmp, name))
|
|
237
|
+
|
|
238
|
+
expect { systemd.install(item) }.to raise_error(RubyRaider::Flows::Error, /systemd can not use the path/)
|
|
239
|
+
end
|
|
240
|
+
expect([runner.calls, File.exist?(units)]).to eq([[], false])
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
it 'leaves no temp file behind when writing one fails' do
|
|
244
|
+
allow(File).to receive(:open).and_call_original
|
|
245
|
+
allow(File).to receive(:open).with(/\.timer\..*\.tmp\z/, any_args).and_wrap_original do |original, path, *rest, **options|
|
|
246
|
+
original.call(path, *rest, **options) { raise Errno::ENOSPC }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
expect { systemd.install(schedule) }.to raise_error(RubyRaider::Flows::Error, /Could not write .*No space left/)
|
|
250
|
+
expect([runner.calls, Dir.children(units)]).to eq([[], []])
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
it 'raises a flow error and runs nothing when it can not write the units' do
|
|
254
|
+
FileUtils.mkdir_p(File.dirname(units))
|
|
255
|
+
File.write(units, 'not a folder')
|
|
256
|
+
|
|
257
|
+
expect { systemd.install(schedule) }.to raise_error(RubyRaider::Flows::Error, /Could not write .*systemd/)
|
|
258
|
+
expect([runner.calls, File.read(units)]).to eq([[], 'not a folder'])
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
describe '#remove' do
|
|
263
|
+
it 'disables the timer, deletes both units and reloads' do
|
|
264
|
+
item = schedule
|
|
265
|
+
FileUtils.mkdir_p(units)
|
|
266
|
+
File.write(service_path(item), 'x')
|
|
267
|
+
File.write(timer_path(item), 'x')
|
|
268
|
+
|
|
269
|
+
expect(systemd.remove(item.os_id)).to be(true)
|
|
270
|
+
expect(runner.calls).to eq([['systemctl', '--user', 'disable', '--now', "#{item.os_id}.timer"], %w[systemctl --user daemon-reload]])
|
|
271
|
+
expect(Dir.children(units)).to be_empty
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
it 'is false when it was already gone, even if disable fails' do
|
|
275
|
+
runner.answers['disable'] = ['Unit file does not exist', 1]
|
|
276
|
+
|
|
277
|
+
expect(systemd.remove(schedule.os_id)).to be(false)
|
|
278
|
+
expect(runner.calls.map { |argv| argv[2] }).to eq(%w[disable daemon-reload])
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
it 'raises when a unit can not be deleted' do
|
|
282
|
+
item = schedule
|
|
283
|
+
allow(File).to receive(:unlink).and_raise(Errno::EACCES)
|
|
284
|
+
|
|
285
|
+
expect { systemd.remove(item.os_id) }.to raise_error(RubyRaider::Flows::Error, /Could not remove .*Permission denied/)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
it 'raises when the reload fails' do
|
|
289
|
+
runner.answers['daemon-reload'] = ['Failed to connect to bus', 1]
|
|
290
|
+
|
|
291
|
+
expect { systemd.remove(schedule.os_id) }.to raise_error(RubyRaider::Flows::Error, /Failed to connect to bus/)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
it 'refuses an os_id that is not a flow schedule id' do
|
|
295
|
+
expect { systemd.remove('../../evil') }.to raise_error(RubyRaider::Flows::Error, /Invalid os_id/)
|
|
296
|
+
expect(runner.calls).to be_empty
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
describe '#usable?' do
|
|
301
|
+
it 'asks the user manager for its environment' do
|
|
302
|
+
expect(systemd.usable?).to be(true)
|
|
303
|
+
runner.answers['show-environment'] = ['Failed to connect to bus', 1]
|
|
304
|
+
expect(systemd.usable?).to be(false)
|
|
305
|
+
expect(runner.calls).to eq([%w[systemctl --user show-environment]] * 2)
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
describe '#present?' do
|
|
310
|
+
it 'asks whether the timer is enabled' do
|
|
311
|
+
item = schedule
|
|
312
|
+
|
|
313
|
+
expect(systemd.present?(item.os_id)).to be(true)
|
|
314
|
+
expect(runner.calls).to eq([['systemctl', '--user', 'is-enabled', "#{item.os_id}.timer"]])
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
it 'is false when the timer is not enabled' do
|
|
318
|
+
runner.answers['is-enabled'] = ['disabled', 1]
|
|
319
|
+
|
|
320
|
+
expect(systemd.present?(schedule.os_id)).to be(false)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
it 'refuses an os_id that is not a flow schedule id and runs nothing' do
|
|
324
|
+
expect { systemd.present?('x; rm') }.to raise_error(RubyRaider::Flows::Error, /Invalid os_id/)
|
|
325
|
+
expect(runner.calls).to be_empty
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
# rubocop:enable RSpec/MultipleMemoizedHelpers
|