Skip to content

Commit d5c3fc0

Browse files
committed
Add tests for Choria transport phases 1 and 2
Attempts to minimize stubbing (although we still need a fair bit) and use the choria-mcorpc-support gem as much as possible.
1 parent 259b25f commit d5c3fc0

9 files changed

Lines changed: 3244 additions & 0 deletions

File tree

spec/lib/bolt_spec/choria.rb

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
# frozen_string_literal: true
2+
3+
require 'bolt/inventory'
4+
require 'bolt/transport/choria'
5+
require 'mcollective'
6+
require 'tempfile'
7+
8+
module BoltSpec
9+
# Shared helper methods for Choria transport specs.
10+
module Choria
11+
# Write a minimal Choria config file to a Tempfile. Returns the Tempfile
12+
# object (call .path to get the path).
13+
#
14+
# Default config suppresses log output. Pass overrides as key-value pairs
15+
# matching Choria config file syntax.
16+
#
17+
# write_choria_config(main_collective: 'production')
18+
def write_choria_config(**overrides)
19+
defaults = { logger_type: 'console', loglevel: 'error' }
20+
config = defaults.merge(overrides)
21+
22+
file = Tempfile.new(['choria-test', '.conf'])
23+
config.each { |key, value| file.puts("#{key} = #{value}") }
24+
file.flush
25+
file
26+
end
27+
28+
# Build a real MCollective::RPC::Result matching the format the
29+
# RPC client returns. The Result class delegates [] to an internal
30+
# hash, so code accessing result[:sender], result[:data], etc. works.
31+
def make_rpc_result(sender:, statuscode: 0, statusmsg: 'OK', data: {})
32+
identity = sender.is_a?(Bolt::Target) ? transport.choria_identity(sender) : sender
33+
MCollective::RPC::Result.new('test', 'test',
34+
sender: identity, statuscode: statuscode, statusmsg: statusmsg, data: data)
35+
end
36+
37+
# Stub agent discovery for one or more targets. Accumulates across
38+
# calls so different targets can have different agent lists. Calling
39+
# again for the same target replaces that target's entry. Instance
40+
# variables are cleared between `it` blocks.
41+
#
42+
# Accepts Bolt::Target objects or host strings, single or as an array.
43+
# Agents can be strings (version defaults to '1.2.0') or [name, version]
44+
# pairs for version-specific scenarios.
45+
#
46+
# stub_agents(target, %w[rpcutil shell])
47+
# stub_agents(target2, %w[rpcutil bolt_tasks])
48+
# stub_agents([target, target2], %w[rpcutil bolt_tasks])
49+
# stub_agents(target, [['shell', '1.1.0']], os_family: 'windows')
50+
def stub_agents(targets, agents, os_family: 'RedHat')
51+
targets = [targets].flatten
52+
53+
agent_data = agents.map do |agent|
54+
name, version = agent.is_a?(Array) ? agent : [agent, '1.2.0']
55+
{ 'agent' => name, 'name' => name, 'version' => version }
56+
end
57+
58+
@stub_inventory_results ||= []
59+
@stub_fact_results ||= []
60+
61+
# Replace existing entries for these targets (supports re-stubbing)
62+
new_senders = targets.map { |target| transport.choria_identity(target) }
63+
@stub_inventory_results.reject! { |result| new_senders.include?(result[:sender]) }
64+
@stub_fact_results.reject! { |result| new_senders.include?(result[:sender]) }
65+
66+
targets.each do |target|
67+
@stub_inventory_results << make_rpc_result(sender: target, data: { agents: agent_data })
68+
@stub_fact_results << make_rpc_result(sender: target, data: { value: os_family })
69+
end
70+
71+
allow(mock_rpc_client).to receive_messages(agent_inventory: @stub_inventory_results, get_fact: @stub_fact_results)
72+
end
73+
74+
# --- bolt_tasks result builders ---
75+
76+
def make_download_result(sender, downloads: 1)
77+
make_rpc_result(sender: sender, data: { downloads: downloads })
78+
end
79+
80+
def make_task_run_result(sender, task_id: 'test-task-id')
81+
make_rpc_result(sender: sender, data: { task_id: task_id })
82+
end
83+
84+
def make_task_status_result(sender, stdout: '{"result":"ok"}', stderr: '', exitcode: 0, completed: true)
85+
make_rpc_result(sender: sender, data: {
86+
completed: completed, exitcode: exitcode, stdout: stdout, stderr: stderr
87+
})
88+
end
89+
90+
# --- shell agent result builders ---
91+
92+
def make_shell_run_result(sender, stdout: '', stderr: '', exitcode: 0)
93+
make_rpc_result(sender: sender, data: { stdout: stdout, stderr: stderr, exitcode: exitcode })
94+
end
95+
96+
def make_shell_start_result(sender, handle: 'test-handle-uuid')
97+
make_rpc_result(sender: sender, data: { handle: handle })
98+
end
99+
100+
def make_shell_list_result(sender, handle, status: 'stopped')
101+
make_rpc_result(sender: sender, data: {
102+
jobs: { handle => { 'id' => handle, 'status' => status } }
103+
})
104+
end
105+
106+
def make_shell_statuses_result(sender, handle, stdout: '', stderr: '', exitcode: 0, status: 'stopped')
107+
make_rpc_result(sender: sender, data: {
108+
statuses: { handle => { 'status' => status, 'stdout' => stdout, 'stderr' => stderr, 'exitcode' => exitcode } }
109+
})
110+
end
111+
112+
# --- Shell agent stub helpers ---
113+
# Accept a hash of { target => options }.
114+
#
115+
# stub_shell_start(target => { handle: 'h1' })
116+
# stub_shell_start(target => { handle: 'h1' }, target2 => { handle: 'h2' })
117+
#
118+
# For single-target convenience with defaults, pass keyword args:
119+
# stub_shell_start(stdout: 'ok')
120+
# stub_shell_start # uses target with all defaults
121+
122+
def stub_shell_run(targets = nil, **kwargs)
123+
results = normalize_shell_targets(targets, kwargs).map do |sender, opts|
124+
make_shell_run_result(sender, stdout: '', stderr: '', exitcode: 0, **opts)
125+
end
126+
allow(mock_rpc_client).to receive(:run).and_return(results)
127+
end
128+
129+
def stub_shell_start(targets = nil, **kwargs)
130+
results = normalize_shell_targets(targets, kwargs).map do |sender, opts|
131+
make_shell_start_result(sender, handle: 'test-handle-uuid', **opts)
132+
end
133+
allow(mock_rpc_client).to receive(:start).and_return(results)
134+
end
135+
136+
def stub_shell_list(targets = nil, **kwargs)
137+
results = normalize_shell_targets(targets, kwargs).map do |sender, opts|
138+
handle = opts.delete(:handle) || 'test-handle-uuid'
139+
make_shell_list_result(sender, handle, status: 'stopped', **opts)
140+
end
141+
allow(mock_rpc_client).to receive(:list).and_return(results)
142+
end
143+
144+
def stub_shell_status(targets = nil, **kwargs)
145+
results = normalize_shell_targets(targets, kwargs).map do |sender, opts|
146+
handle = opts.delete(:handle) || 'test-handle-uuid'
147+
make_shell_statuses_result(sender, handle,
148+
stdout: '', stderr: '', exitcode: 0, status: 'stopped', **opts)
149+
end
150+
allow(mock_rpc_client).to receive(:statuses).and_return(results)
151+
end
152+
153+
def stub_shell_kill
154+
allow(mock_rpc_client).to receive(:kill)
155+
end
156+
157+
private
158+
159+
# Normalize arguments into a hash of { target => options }.
160+
# If a target-keyed hash is given, use it directly.
161+
# If only keyword args are given, wrap as { target => kwargs }.
162+
def normalize_shell_targets(targets, kwargs)
163+
if targets.is_a?(Hash)
164+
targets
165+
else
166+
{ target => kwargs }
167+
end
168+
end
169+
end
170+
end
171+
172+
# Base setup for any Choria transport spec. Provides transport, inventory,
173+
# targets, and the mock RPC client. Resets MCollective singleton state
174+
# between tests so each test starts with a clean config.
175+
RSpec.shared_context 'choria transport' do
176+
include BoltSpec::Choria
177+
178+
let(:transport) { Bolt::Transport::Choria.new }
179+
let(:inventory) { Bolt::Inventory.empty }
180+
let(:target) { inventory.get_target('choria://node1.example.com') }
181+
let(:target2) { inventory.get_target('choria://node2.example.com') }
182+
183+
# Use a plain double rather than instance_double because the real
184+
# MCollective::RPC::Client dispatches agent actions via method_missing,
185+
# so methods like :agent_inventory, :ping, :run, etc. are not actually
186+
# defined on the class and instance_double would reject them.
187+
let(:mock_rpc_client) do
188+
mock_options = { filter: { 'identity' => [] } }
189+
double('MCollective::RPC::Client').tap do |client|
190+
allow(client).to receive(:identity_filter)
191+
allow(client).to receive(:discover) { |**flags| mock_options[:filter]['identity'] = flags[:nodes] || [] }
192+
allow(client).to receive(:progress=)
193+
allow(client).to receive(:options).and_return(mock_options)
194+
end
195+
end
196+
197+
before(:each) do
198+
# Reset MCollective singleton state so each test starts clean.
199+
@choria_config_file = write_choria_config
200+
mc_config = MCollective::Config.instance
201+
mc_config.set_config_defaults(@choria_config_file.path)
202+
mc_config.instance_variable_set(:@configured, false)
203+
MCollective::PluginManager.clear
204+
205+
# Point targets at the temp config so configure_client uses it
206+
# instead of auto-detecting from the filesystem.
207+
inventory.set_config(target, 'transport', 'choria')
208+
inventory.set_config(target, %w[choria config-file], @choria_config_file.path)
209+
inventory.set_config(target2, 'transport', 'choria')
210+
inventory.set_config(target2, %w[choria config-file], @choria_config_file.path)
211+
212+
# Stub the RPC client constructor. This is the only MCollective
213+
# stub we need -- it prevents the real client from connecting to
214+
# NATS via TCP during construction.
215+
allow(MCollective::RPC::Client).to receive(:new).and_return(mock_rpc_client)
216+
217+
# Stub sleep so polling loops don't actually wait.
218+
allow(transport).to receive(:sleep)
219+
220+
# Default OS detection stub. Tests that need a different OS family
221+
# (e.g. Windows) can override via stub_agents with os_family: param.
222+
allow(mock_rpc_client).to receive(:get_fact).and_return([
223+
make_rpc_result(sender: target, data: { value: 'RedHat' }),
224+
make_rpc_result(sender: target2, data: { value: 'RedHat' })
225+
])
226+
end
227+
228+
after(:each) do
229+
@choria_config_file&.close!
230+
end
231+
end
232+
233+
# Configures the client for multi-target tests.
234+
RSpec.shared_context 'choria multi-target' do
235+
before(:each) do
236+
transport.configure_client(target)
237+
end
238+
end
239+
240+
# Task object and metadata for task execution tests.
241+
RSpec.shared_context 'choria task' do
242+
let(:task_name) { 'mymod::mytask' }
243+
let(:task_executable) { '/path/to/mymod/tasks/mytask.sh' }
244+
let(:task_content) { "#!/bin/bash\necho '{\"result\": \"ok\"}'" }
245+
let(:task) do
246+
Bolt::Task.new(
247+
task_name,
248+
{ 'input_method' => 'both' },
249+
[{ 'name' => 'mytask.sh', 'path' => task_executable }]
250+
)
251+
end
252+
253+
# Task with only a Linux (shell) implementation.
254+
let(:linux_only_task) do
255+
Bolt::Task.new(
256+
'mymod::linuxtask',
257+
{
258+
'input_method' => 'both',
259+
'implementations' => [
260+
{ 'name' => 'linuxtask.sh', 'requirements' => ['shell'] }
261+
]
262+
},
263+
[{ 'name' => 'linuxtask.sh', 'path' => '/path/to/linuxtask.sh' }]
264+
)
265+
end
266+
267+
# Task with only a Windows (PowerShell) implementation.
268+
let(:windows_only_task) do
269+
Bolt::Task.new(
270+
'mymod::wintask',
271+
{
272+
'input_method' => 'both',
273+
'implementations' => [
274+
{ 'name' => 'wintask.ps1', 'requirements' => ['powershell'] }
275+
]
276+
},
277+
[{ 'name' => 'wintask.ps1', 'path' => '/path/to/wintask.ps1' }]
278+
)
279+
end
280+
281+
# Task with implementations for both platforms.
282+
let(:cross_platform_task) do
283+
Bolt::Task.new(
284+
'mymod::crosstask',
285+
{
286+
'input_method' => 'both',
287+
'implementations' => [
288+
{ 'name' => 'crosstask.ps1', 'requirements' => ['powershell'] },
289+
{ 'name' => 'crosstask.sh', 'requirements' => ['shell'] }
290+
]
291+
},
292+
[
293+
{ 'name' => 'crosstask.ps1', 'path' => '/path/to/crosstask.ps1' },
294+
{ 'name' => 'crosstask.sh', 'path' => '/path/to/crosstask.sh' }
295+
]
296+
)
297+
end
298+
end
299+
300+
# File system stubs for task executables. Stubs SHA256, File.size,
301+
# File.binread, File.basename, and SecureRandom.uuid so both
302+
# bolt_tasks (download manifest) and shell (file upload) paths work.
303+
RSpec.shared_context 'choria task file stubs' do
304+
before(:each) do
305+
mock_digest = instance_double(Digest::SHA256, hexdigest: Digest::SHA256.hexdigest(task_content))
306+
allow(Digest::SHA256).to receive(:file).and_call_original
307+
allow(Digest::SHA256).to receive(:file).with(task_executable).and_return(mock_digest)
308+
allow(File).to receive(:size).and_call_original
309+
allow(File).to receive(:size).with(task_executable).and_return(task_content.bytesize)
310+
allow(File).to receive(:binread).and_call_original
311+
allow(File).to receive(:binread).with(task_executable).and_return(task_content)
312+
allow(File).to receive(:basename).and_call_original
313+
allow(SecureRandom).to receive(:uuid).and_return('test-uuid')
314+
end
315+
end
316+
317+
# File system stubs for script execution tests. Expects script_path and
318+
# script_content to be defined via let in the including context.
319+
RSpec.shared_context 'choria script file stubs' do
320+
before(:each) do
321+
allow(File).to receive(:binread).and_call_original
322+
allow(File).to receive(:binread).with(script_path).and_return(script_content)
323+
allow(File).to receive(:basename).and_call_original
324+
allow(File).to receive(:basename).with(script_path).and_return(File.basename(script_path))
325+
allow(SecureRandom).to receive(:uuid).and_return('test-uuid')
326+
end
327+
end

0 commit comments

Comments
 (0)