hybrid_platforms_conductor 32.7.3 → 32.9.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/hybrid_platforms_conductor/hpc_plugins/connector/ssh.rb +18 -8
- data/lib/hybrid_platforms_conductor/hpc_plugins/provisioner/proxmox.rb +47 -16
- data/lib/hybrid_platforms_conductor/hpc_plugins/provisioner/proxmox/proxmox_waiter.rb +19 -2
- data/lib/hybrid_platforms_conductor/tests_runner.rb +34 -30
- data/lib/hybrid_platforms_conductor/version.rb +1 -1
- data/spec/hybrid_platforms_conductor_test.rb +1 -0
- data/spec/hybrid_platforms_conductor_test/api/actions_executor/connectors/ssh/connections_spec.rb +52 -0
- data/spec/hybrid_platforms_conductor_test/api/deployer/provisioners/proxmox/reserve_proxmox_container/retries_spec.rb +64 -0
- data/spec/hybrid_platforms_conductor_test/api/deployer/provisioners/proxmox/start_spec.rb +3 -21
- data/spec/hybrid_platforms_conductor_test/api/deployer/provisioners/proxmox/state_spec.rb +26 -0
- data/spec/hybrid_platforms_conductor_test/api/tests_runner/node_ssh_spec.rb +15 -2
- data/spec/hybrid_platforms_conductor_test/helpers/connector_ssh_helpers.rb +8 -6
- data/spec/hybrid_platforms_conductor_test/helpers/provisioner_proxmox_helpers.rb +81 -24
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4f1beb4de64ac4dcdc1de3e5063980ea4dab1b8ceef204baae0a9f4bf209f1f3
|
4
|
+
data.tar.gz: 0c5e65553da39646554d737c8a690939497a8c89f602a4eae01a4987fff994b7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3129d3b37140c6488b2b6a8b6c1c8cb22ce745181a643925d9bb2fdc77de97a331660c8d5193dde940535d58b545576796d22a29008d181c73bbf7bb384c2251
|
7
|
+
data.tar.gz: 187c6215c37e89b924693c1819fea12f8cc444858ac575f9445b535ae9866a396b01dbc5baaf85ca4e8163d60c8865c931eca319c4824148fdd06029920b9710
|
@@ -505,15 +505,25 @@ module HybridPlatformsConductor
|
|
505
505
|
if @nodes_handler.get_ssh_session_exec_of(node) == 'false'
|
506
506
|
# Here we have to create a ControlMaster using an interactive session, as the SSH server prohibits ExecSession, and so command executions.
|
507
507
|
# We'll do that using another terminal spawned in the background.
|
508
|
-
|
509
|
-
|
510
|
-
|
511
|
-
|
508
|
+
if ENV['hpc_interactive'] == 'false'
|
509
|
+
error = "Can't spawn interactive ControlMaster to #{node} in non-interactive mode. You may want to change the hpc_interactive env variable."
|
510
|
+
if no_exception
|
511
|
+
log_error error
|
512
|
+
exit_status = :non_interactive
|
513
|
+
else
|
514
|
+
raise error
|
515
|
+
end
|
516
|
+
else
|
517
|
+
Thread.new do
|
518
|
+
log_debug "[ ControlMaster - #{ssh_url} ] - Spawn interactive ControlMaster in separate terminal"
|
519
|
+
@cmd_runner.run_cmd "xterm -e '#{ssh_exec} -o ControlMaster=yes -o ControlPersist=yes #{ssh_url}'", log_to_stdout: log_debug?
|
520
|
+
log_debug "[ ControlMaster - #{ssh_url} ] - Separate interactive ControlMaster closed"
|
521
|
+
end
|
522
|
+
out 'External ControlMaster has been spawned.'
|
523
|
+
out 'Please login into it, keep its session opened and press enter here when done...'
|
524
|
+
$stdin.gets
|
525
|
+
exit_status = 0
|
512
526
|
end
|
513
|
-
out 'External ControlMaster has been spawned.'
|
514
|
-
out 'Please login into it, keep its session opened and press enter here when done...'
|
515
|
-
$stdin.gets
|
516
|
-
exit_status = 0
|
517
527
|
else
|
518
528
|
# Create the control master
|
519
529
|
ssh_control_master_start_cmd = "#{ssh_exec}#{@passwords.key?(node) || @auth_password ? '' : ' -o BatchMode=yes'} -o ControlMaster=yes -o ControlPersist=yes #{ssh_url} true"
|
@@ -23,6 +23,13 @@ module HybridPlatformsConductor
|
|
23
23
|
super
|
24
24
|
end
|
25
25
|
|
26
|
+
# Re-authenticate the Proxmox instance
|
27
|
+
# This can be useful when the API returns errors due to invalidated tokens
|
28
|
+
def reauthenticate
|
29
|
+
log_info 'Force re-authentication to Proxmox'
|
30
|
+
@auth_params = create_ticket
|
31
|
+
end
|
32
|
+
|
26
33
|
end
|
27
34
|
::Proxmox::Proxmox.prepend ProxmoxPatches
|
28
35
|
|
@@ -74,13 +81,13 @@ module HybridPlatformsConductor
|
|
74
81
|
# First check if we already have a test container that corresponds to this node and environment
|
75
82
|
@lxc_details = nil
|
76
83
|
with_proxmox do |proxmox|
|
77
|
-
proxmox
|
84
|
+
proxmox_get(proxmox, 'nodes').each do |node_info|
|
78
85
|
if proxmox_test_info[:test_config][:pve_nodes].include?(node_info['node']) && node_info['status'] == 'online'
|
79
|
-
proxmox
|
86
|
+
proxmox_get(proxmox, "nodes/#{node_info['node']}/lxc").each do |lxc_info|
|
80
87
|
vm_id = Integer(lxc_info['vmid'])
|
81
88
|
if vm_id.between?(*proxmox_test_info[:test_config][:vm_ids_range])
|
82
89
|
# Check if the description contains our ID
|
83
|
-
lxc_config = proxmox
|
90
|
+
lxc_config = proxmox_get(proxmox, "nodes/#{node_info['node']}/lxc/#{vm_id}/config")
|
84
91
|
vm_description_lines = (lxc_config['description'] || '').split("\n")
|
85
92
|
hpc_marker_idx = vm_description_lines.index('===== HPC info =====')
|
86
93
|
unless hpc_marker_idx.nil?
|
@@ -222,8 +229,8 @@ module HybridPlatformsConductor
|
|
222
229
|
with_proxmox do |proxmox|
|
223
230
|
vm_id_str = @lxc_details[:vm_id].to_s
|
224
231
|
status =
|
225
|
-
if proxmox
|
226
|
-
status_info = proxmox
|
232
|
+
if proxmox_get(proxmox, "nodes/#{@lxc_details[:pve_node]}/lxc").any? { |data_info| data_info['vmid'] == vm_id_str }
|
233
|
+
status_info = proxmox_get(proxmox, "nodes/#{@lxc_details[:pve_node]}/lxc/#{@lxc_details[:vm_id]}/status/current")
|
227
234
|
# Careful that it is possible that somebody destroyed the VM and so its status is missing
|
228
235
|
status = status_info.key?('status') ? status_info['status'].to_sym : :missing
|
229
236
|
status = :exited if status == :stopped
|
@@ -292,11 +299,29 @@ module HybridPlatformsConductor
|
|
292
299
|
end
|
293
300
|
end
|
294
301
|
|
295
|
-
#
|
296
|
-
|
297
|
-
|
298
|
-
#
|
299
|
-
|
302
|
+
# Perform a get operation on the API
|
303
|
+
# Protect the get API methods with a retry mechanism in case of 5xx errors.
|
304
|
+
#
|
305
|
+
# Parameters::
|
306
|
+
# * *proxmox* (Proxmox): The Proxmox instance
|
307
|
+
# * *path* (String): Path to get
|
308
|
+
# Result::
|
309
|
+
# * Object: API response
|
310
|
+
def proxmox_get(proxmox, path)
|
311
|
+
response = nil
|
312
|
+
idx_try = 0
|
313
|
+
loop do
|
314
|
+
response = proxmox.get(path)
|
315
|
+
break if !(response.is_a?(String)) || !(response =~ /^NOK: error code = 5\d\d$/)
|
316
|
+
log_warn "[ #{@node}/#{@environment} ] - Proxmox API call get #{path} returned error #{response} (attempt ##{idx_try}/#{proxmox_test_info[:api_max_retries]})"
|
317
|
+
raise "[ #{@node}/#{@environment} ] - Proxmox API call get #{path} returns #{response} continuously (tried #{idx_try + 1} times)" if idx_try >= proxmox_test_info[:api_max_retries]
|
318
|
+
idx_try += 1
|
319
|
+
# We have to reauthenticate: error 500 raised by Proxmox are often due to token being invalidated wrongly
|
320
|
+
proxmox.reauthenticate
|
321
|
+
sleep proxmox_test_info[:api_wait_between_retries_secs] + rand(5)
|
322
|
+
end
|
323
|
+
response
|
324
|
+
end
|
300
325
|
|
301
326
|
# Run a Proxmox task.
|
302
327
|
# Handle a retry mechanism in case of 5xx errors.
|
@@ -313,11 +338,13 @@ module HybridPlatformsConductor
|
|
313
338
|
while task.nil? do
|
314
339
|
task = proxmox.send(http_method, "nodes/#{pve_node}/#{sub_path}", *args)
|
315
340
|
if task =~ /^NOK: error code = 5\d\d$/
|
316
|
-
log_warn "[ #{@node}/#{@environment} ] - Proxmox API call #{http_method} nodes/#{pve_node}/#{sub_path} #{args} returned error #{task} (attempt ##{idx_try}/#{
|
341
|
+
log_warn "[ #{@node}/#{@environment} ] - Proxmox API call #{http_method} nodes/#{pve_node}/#{sub_path} #{args} returned error #{task} (attempt ##{idx_try}/#{proxmox_test_info[:api_max_retries]})"
|
317
342
|
task = nil
|
343
|
+
break if idx_try >= proxmox_test_info[:api_max_retries]
|
318
344
|
idx_try += 1
|
319
|
-
|
320
|
-
|
345
|
+
# We have to reauthenticate: error 500 raised by Proxmox are often due to token being invalidated wrongly
|
346
|
+
proxmox.reauthenticate
|
347
|
+
sleep proxmox_test_info[:api_wait_between_retries_secs] + rand(5)
|
321
348
|
end
|
322
349
|
end
|
323
350
|
if task.nil?
|
@@ -358,7 +385,7 @@ module HybridPlatformsConductor
|
|
358
385
|
# Result::
|
359
386
|
# * String: The task status
|
360
387
|
def task_status(proxmox, pve_node, task)
|
361
|
-
status_info = proxmox
|
388
|
+
status_info = proxmox_get(proxmox, "nodes/#{pve_node}/tasks/#{task}/status")
|
362
389
|
"#{status_info['status']}#{status_info['exitstatus'] ? ":#{status_info['exitstatus']}" : ''}"
|
363
390
|
end
|
364
391
|
|
@@ -377,7 +404,9 @@ module HybridPlatformsConductor
|
|
377
404
|
(proxmox_test_info[:test_config].merge(
|
378
405
|
proxmox_api_url: proxmox_test_info[:api_url],
|
379
406
|
futex_file: '/tmp/hpc_proxmox_allocations.futex',
|
380
|
-
logs_dir: '/tmp/hpc_proxmox_waiter_logs'
|
407
|
+
logs_dir: '/tmp/hpc_proxmox_waiter_logs',
|
408
|
+
api_max_retries: proxmox_test_info[:api_max_retries],
|
409
|
+
api_wait_between_retries_secs: proxmox_test_info[:api_wait_between_retries_secs]
|
381
410
|
)).to_json
|
382
411
|
)
|
383
412
|
result = nil
|
@@ -486,7 +515,7 @@ module HybridPlatformsConductor
|
|
486
515
|
# So remaining length is 255 - 13 = 242 characters.
|
487
516
|
MAX_FILE_ID_SIZE = 242
|
488
517
|
|
489
|
-
# Get an ID unique for
|
518
|
+
# Get an ID unique for this node/environment and that can be used in file names.
|
490
519
|
#
|
491
520
|
# Result::
|
492
521
|
# * String: ID
|
@@ -506,6 +535,8 @@ module HybridPlatformsConductor
|
|
506
535
|
# Result::
|
507
536
|
# * Hash<Symbol,Object>: Configuration of the Proxmox instance to be used:
|
508
537
|
# * *api_url* (String): The Proxmox API URL
|
538
|
+
# * *api_max_retries* (Integer): Max number of API retries
|
539
|
+
# * *api_wait_between_retries_secs* (Integer): Number of seconds to wait between API retries
|
509
540
|
# * *sync_node* (String): Node to be used to synchronize Proxmox resources acquisition
|
510
541
|
# * *test_config* (Hash<Symbol,Object>): The test configuration. Check ProxmoxWaiter#initialize (config_file structure) method to get details.
|
511
542
|
# * *vm_config* (Hash<Symbol,Object>): Extra configuration of a created container. Check #request_lxc_creation_for results to get details.
|
@@ -26,6 +26,8 @@ class ProxmoxWaiter
|
|
26
26
|
# * *proxmox_api_url* (String): Proxmox API URL.
|
27
27
|
# * *futex_file* (String): Path to the file serving as a futex.
|
28
28
|
# * *logs_dir* (String): Path to the directory containing logs [default: '.']
|
29
|
+
# * *api_max_retries* (Integer): Max number of API retries
|
30
|
+
# * *api_wait_between_retries_secs* (Integer): Number of seconds to wait between API retries
|
29
31
|
# * *pve_nodes* (Array<String>): List of PVE nodes allowed to spawn new containers [default: all]
|
30
32
|
# * *vm_ips_list* (Array<String>): The list of IPs that are available for the Proxomx containers.
|
31
33
|
# * *vm_ids_range* ([Integer, Integer]): Minimum and maximum reservable VM ID
|
@@ -637,11 +639,26 @@ class ProxmoxWaiter
|
|
637
639
|
|
638
640
|
# Get a path from the API it returns its JSON result.
|
639
641
|
# Keep a cache of it, whose lifespan is this ProxmoxWaiter instance.
|
642
|
+
# Have a retry mechanism to make sure eventual non-deterministic 5xx errors are not an issue.
|
640
643
|
#
|
641
644
|
# Parameters::
|
642
645
|
# * *path* (String): API path to query
|
643
|
-
|
644
|
-
|
646
|
+
# Result::
|
647
|
+
# * Object: The API response
|
648
|
+
def api_get(path, nbr_retries: 3, wait_between_retry_secs: 10)
|
649
|
+
unless @gets_cache.key?(path)
|
650
|
+
idx_try = 0
|
651
|
+
loop do
|
652
|
+
@gets_cache[path] = @proxmox.get(path)
|
653
|
+
break unless @gets_cache[path].is_a?(String) && @gets_cache[path] =~ /^NOK: error code = 5\d\d$/
|
654
|
+
raise "Proxmox API get #{path} returns #{@gets_cache[path]} continuously (tried #{idx_try + 1} times)" if idx_try >= @config['api_max_retries']
|
655
|
+
idx_try += 1
|
656
|
+
# We have to reauthenticate: error 500 raised by Proxmox are often due to token being invalidated wrongly
|
657
|
+
# TODO: Provide a way to do it properly in the official gem
|
658
|
+
@proxmox.instance_variable_set(:@auth_params, @proxmox.send(:create_ticket))
|
659
|
+
sleep @config['api_wait_between_retries_secs']
|
660
|
+
end
|
661
|
+
end
|
645
662
|
@gets_cache[path]
|
646
663
|
end
|
647
664
|
|
@@ -428,9 +428,11 @@ module HybridPlatformsConductor
|
|
428
428
|
end
|
429
429
|
end
|
430
430
|
# Compute the timeout that will be applied, from the max timeout sum for every node that has tests to run
|
431
|
-
timeout = CONNECTION_TIMEOUT +
|
432
|
-
|
433
|
-
|
431
|
+
timeout = CONNECTION_TIMEOUT + (
|
432
|
+
@cmds_to_run.map do |_node, cmds_list|
|
433
|
+
cmds_list.inject(0) { |total_timeout, (_cmd, test_info)| test_info[:timeout] + total_timeout }
|
434
|
+
end.max || 0
|
435
|
+
)
|
434
436
|
# Run commands on nodes, in grouped way to avoid too many connections, per node
|
435
437
|
# Hash< String, Array<String> >
|
436
438
|
@test_cmds = Hash[@cmds_to_run.map do |node, cmds_list|
|
@@ -464,33 +466,35 @@ module HybridPlatformsConductor
|
|
464
466
|
end,
|
465
467
|
test_execution: proc do |test|
|
466
468
|
exit_status, stdout, stderr = @actions_result[test.node]
|
467
|
-
|
468
|
-
|
469
|
-
|
470
|
-
|
471
|
-
|
472
|
-
|
473
|
-
|
474
|
-
|
475
|
-
|
476
|
-
|
477
|
-
|
478
|
-
|
479
|
-
|
480
|
-
|
481
|
-
|
482
|
-
|
483
|
-
|
484
|
-
|
485
|
-
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
490
|
-
|
491
|
-
|
492
|
-
|
493
|
-
|
469
|
+
unless exit_status.nil?
|
470
|
+
if exit_status.is_a?(Symbol)
|
471
|
+
test.error "Error while executing tests: #{exit_status}: #{stderr}"
|
472
|
+
else
|
473
|
+
log_debug <<~EOS
|
474
|
+
----- Commands for #{test.node}:
|
475
|
+
#{@test_cmds[test.node][:remote_bash].join("\n")}
|
476
|
+
----- STDOUT:
|
477
|
+
#{stdout}
|
478
|
+
----- STDERR:
|
479
|
+
#{stderr}
|
480
|
+
-----
|
481
|
+
EOS
|
482
|
+
# Skip the first section, as it can contain SSH banners
|
483
|
+
cmd_stdouts = stdout.split("#{CMD_SEPARATOR}\n")[1..-1]
|
484
|
+
cmd_stdouts = [] if cmd_stdouts.nil?
|
485
|
+
cmd_stderrs = stderr.split("#{CMD_SEPARATOR}\n")[1..-1]
|
486
|
+
cmd_stderrs = [] if cmd_stderrs.nil?
|
487
|
+
@cmds_to_run[test.node].zip(cmd_stdouts, cmd_stderrs).each do |(cmd, test_info), cmd_stdout, cmd_stderr|
|
488
|
+
# Find the section that corresponds to this test
|
489
|
+
if test_info[:test] == test
|
490
|
+
cmd_stdout = '' if cmd_stdout.nil?
|
491
|
+
cmd_stderr = '' if cmd_stderr.nil?
|
492
|
+
stdout_lines = cmd_stdout.split("\n")
|
493
|
+
# Last line of stdout is the return code
|
494
|
+
return_code = stdout_lines.empty? ? :command_cant_run : Integer(stdout_lines.last)
|
495
|
+
test.error "Command '#{cmd}' returned error code #{return_code}", "----- STDOUT:\n#{stdout_lines[0..-2].join("\n")}\n----- STDERR:\n#{cmd_stderr}" unless return_code == 0
|
496
|
+
test_info[:validator].call(stdout_lines[0..-2], cmd_stderr.split("\n"), return_code)
|
497
|
+
end
|
494
498
|
end
|
495
499
|
end
|
496
500
|
end
|
@@ -94,6 +94,7 @@ module HybridPlatformsConductorTest
|
|
94
94
|
ENV.delete 'hpc_password_for_thycotic'
|
95
95
|
ENV.delete 'hpc_domain_for_thycotic'
|
96
96
|
ENV.delete 'hpc_certificates'
|
97
|
+
ENV.delete 'hpc_interactive'
|
97
98
|
# Set the necessary Hybrid Platforms Conductor environment variables
|
98
99
|
ENV['hpc_ssh_user'] = 'test_user'
|
99
100
|
HybridPlatformsConductor::ServicesHandler.packaged_deployments.clear
|
data/spec/hybrid_platforms_conductor_test/api/actions_executor/connectors/ssh/connections_spec.rb
CHANGED
@@ -44,6 +44,58 @@ describe HybridPlatformsConductor::ActionsExecutor do
|
|
44
44
|
end
|
45
45
|
end
|
46
46
|
|
47
|
+
it 'can\'t create an SSH master to 1 node not having Session Exec capabilities when hpc_interactive is false' do
|
48
|
+
with_test_platform(nodes: { 'node' => { meta: { host_ip: '192.168.42.42', ssh_session_exec: 'false' } } }) do
|
49
|
+
ENV['hpc_interactive'] = 'false'
|
50
|
+
with_cmd_runner_mocked(
|
51
|
+
[
|
52
|
+
['which env', proc { [0, "/usr/bin/env\n", ''] }],
|
53
|
+
['ssh -V 2>&1', proc { [0, "OpenSSH_7.4p1 Debian-10+deb9u7, OpenSSL 1.0.2u 20 Dec 2019\n", ''] }]
|
54
|
+
] + ssh_expected_commands_for(
|
55
|
+
{ 'node' => { connection: '192.168.42.42', user: 'test_user' } },
|
56
|
+
with_control_master_create: false,
|
57
|
+
with_control_master_destroy: false
|
58
|
+
)
|
59
|
+
) do
|
60
|
+
test_connector.ssh_user = 'test_user'
|
61
|
+
expect do
|
62
|
+
test_connector.with_connection_to(['node']) do
|
63
|
+
end
|
64
|
+
end.to raise_error 'Can\'t spawn interactive ControlMaster to node in non-interactive mode. You may want to change the hpc_interactive env variable.'
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
it 'fails without creating exception when creating an SSH master to 1 node not having Session Exec capabilities when hpc_interactive is false and we use no_exception' do
|
70
|
+
with_test_platform(nodes: {
|
71
|
+
'node1' => { meta: { host_ip: '192.168.42.1' } },
|
72
|
+
'node2' => { meta: { host_ip: '192.168.42.2', ssh_session_exec: 'false' } },
|
73
|
+
'node3' => { meta: { host_ip: '192.168.42.3' } }
|
74
|
+
}) do
|
75
|
+
ENV['hpc_interactive'] = 'false'
|
76
|
+
with_cmd_runner_mocked(
|
77
|
+
[
|
78
|
+
['which env', proc { [0, "/usr/bin/env\n", ''] }],
|
79
|
+
['ssh -V 2>&1', proc { [0, "OpenSSH_7.4p1 Debian-10+deb9u7, OpenSSL 1.0.2u 20 Dec 2019\n", ''] }]
|
80
|
+
] + ssh_expected_commands_for(
|
81
|
+
'node1' => { connection: '192.168.42.1', user: 'test_user' },
|
82
|
+
'node3' => { connection: '192.168.42.3', user: 'test_user' }
|
83
|
+
) + ssh_expected_commands_for(
|
84
|
+
{
|
85
|
+
'node2' => { connection: '192.168.42.2', user: 'test_user' }
|
86
|
+
},
|
87
|
+
with_control_master_create: false,
|
88
|
+
with_control_master_destroy: false
|
89
|
+
)
|
90
|
+
) do
|
91
|
+
test_connector.ssh_user = 'test_user'
|
92
|
+
test_connector.with_connection_to(%w[node1 node2 node3], no_exception: true) do |connected_nodes|
|
93
|
+
expect(connected_nodes.sort).to eq %w[node1 node3].sort
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
47
99
|
it 'creates SSH master to several nodes' do
|
48
100
|
with_test_platform(nodes: {
|
49
101
|
'node1' => { meta: { host_ip: '192.168.42.1' } },
|
@@ -10,6 +10,7 @@ describe HybridPlatformsConductor::HpcPlugins::Provisioner::Proxmox do
|
|
10
10
|
with_sync_node do
|
11
11
|
mock_proxmox(mocked_pve_nodes: [{ 'pve_node_name' => {} }] * 5)
|
12
12
|
expect(call_reserve_proxmox_container(2, 128 * 1024, 4, max_retries: 5)).to eq(error: 'not_enough_resources')
|
13
|
+
expect_proxmox_actions_to_be []
|
13
14
|
end
|
14
15
|
end
|
15
16
|
|
@@ -25,6 +26,69 @@ describe HybridPlatformsConductor::HpcPlugins::Provisioner::Proxmox do
|
|
25
26
|
vm_id: 1000,
|
26
27
|
vm_ip: '192.168.0.100'
|
27
28
|
)
|
29
|
+
expect_proxmox_actions_to_be [
|
30
|
+
[
|
31
|
+
:post,
|
32
|
+
'nodes/pve_node_name/lxc',
|
33
|
+
{
|
34
|
+
'ostemplate' => 'test_template.iso',
|
35
|
+
'hostname' => 'test.hostname.my-domain.com',
|
36
|
+
'description' => /node: test_node\nenvironment: test_env/,
|
37
|
+
'cores' => 2,
|
38
|
+
'cpulimit' => 2,
|
39
|
+
'memory' => 1024,
|
40
|
+
'rootfs' => 'local-lvm:4',
|
41
|
+
'net0' => 'name=eth0,bridge=vmbr0,gw=172.16.16.16,ip=192.168.0.100/32',
|
42
|
+
'vmid' => 1000
|
43
|
+
}
|
44
|
+
]
|
45
|
+
]
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'retries a few times before ending in error for a 5xx API error' do
|
50
|
+
with_sync_node do
|
51
|
+
mock_proxmox(mocked_pve_nodes: [{ 'pve_node_name' => { error_strings: ['NOK: error code = 500'] * 5 } }])
|
52
|
+
result = call_reserve_proxmox_container(2, 1024, 4, config: { api_max_retries: 4 })
|
53
|
+
expect(result[:error]).not_to eq nil
|
54
|
+
expect(result[:error]).to match /Unhandled exception from reserve_proxmox_container: Proxmox API get nodes\/pve_node_name\/lxc returns NOK: error code = 500 continuously \(tried 5 times\)/
|
55
|
+
expect_proxmox_actions_to_be [
|
56
|
+
[:create_ticket],
|
57
|
+
[:create_ticket],
|
58
|
+
[:create_ticket],
|
59
|
+
[:create_ticket]
|
60
|
+
]
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
it 'retries API errors a few times until it gets resolved' do
|
65
|
+
with_sync_node do
|
66
|
+
mock_proxmox(mocked_pve_nodes: [{ 'pve_node_name' => { error_strings: ['NOK: error code = 500'] * 3 } }])
|
67
|
+
expect(call_reserve_proxmox_container(2, 1024, 4, config: { api_max_retries: 4 })).to eq(
|
68
|
+
pve_node: 'pve_node_name',
|
69
|
+
vm_id: 1000,
|
70
|
+
vm_ip: '192.168.0.100'
|
71
|
+
)
|
72
|
+
expect_proxmox_actions_to_be [
|
73
|
+
[:create_ticket],
|
74
|
+
[:create_ticket],
|
75
|
+
[:create_ticket],
|
76
|
+
[
|
77
|
+
:post,
|
78
|
+
'nodes/pve_node_name/lxc',
|
79
|
+
{
|
80
|
+
'ostemplate' => 'test_template.iso',
|
81
|
+
'hostname' => 'test.hostname.my-domain.com',
|
82
|
+
'description' => /node: test_node\nenvironment: test_env/,
|
83
|
+
'cores' => 2,
|
84
|
+
'cpulimit' => 2,
|
85
|
+
'memory' => 1024,
|
86
|
+
'rootfs' => 'local-lvm:4',
|
87
|
+
'net0' => 'name=eth0,bridge=vmbr0,gw=172.16.16.16,ip=192.168.0.100/32',
|
88
|
+
'vmid' => 1000
|
89
|
+
}
|
90
|
+
]
|
91
|
+
]
|
28
92
|
end
|
29
93
|
end
|
30
94
|
|
@@ -39,16 +39,7 @@ describe HybridPlatformsConductor::HpcPlugins::Provisioner::Proxmox do
|
|
39
39
|
mock_proxmox_to_start_node(nbr_api_errors: 3)
|
40
40
|
]
|
41
41
|
instance.create
|
42
|
-
|
43
|
-
old_wait_secs = HybridPlatformsConductor::HpcPlugins::Provisioner::Proxmox.const_get(:RETRY_WAIT_TIME_SECS)
|
44
|
-
begin
|
45
|
-
HybridPlatformsConductor::HpcPlugins::Provisioner::Proxmox.send(:remove_const, :RETRY_WAIT_TIME_SECS)
|
46
|
-
HybridPlatformsConductor::HpcPlugins::Provisioner::Proxmox.const_set(:RETRY_WAIT_TIME_SECS, 1)
|
47
|
-
instance.start
|
48
|
-
ensure
|
49
|
-
HybridPlatformsConductor::HpcPlugins::Provisioner::Proxmox.send(:remove_const, :RETRY_WAIT_TIME_SECS)
|
50
|
-
HybridPlatformsConductor::HpcPlugins::Provisioner::Proxmox.const_set(:RETRY_WAIT_TIME_SECS, old_wait_secs)
|
51
|
-
end
|
42
|
+
instance.start
|
52
43
|
end
|
53
44
|
end
|
54
45
|
|
@@ -58,19 +49,10 @@ describe HybridPlatformsConductor::HpcPlugins::Provisioner::Proxmox do
|
|
58
49
|
# 1 - The info on existing containers
|
59
50
|
mock_proxmox_to_get_nodes_info,
|
60
51
|
# 2 - The start of the container - fail too many times
|
61
|
-
mock_proxmox_to_start_node(nbr_api_errors:
|
52
|
+
mock_proxmox_to_start_node(nbr_api_errors: 4, task_status: nil)
|
62
53
|
]
|
63
54
|
instance.create
|
64
|
-
|
65
|
-
old_wait_secs = HybridPlatformsConductor::HpcPlugins::Provisioner::Proxmox.const_get(:RETRY_WAIT_TIME_SECS)
|
66
|
-
begin
|
67
|
-
HybridPlatformsConductor::HpcPlugins::Provisioner::Proxmox.send(:remove_const, :RETRY_WAIT_TIME_SECS)
|
68
|
-
HybridPlatformsConductor::HpcPlugins::Provisioner::Proxmox.const_set(:RETRY_WAIT_TIME_SECS, 1)
|
69
|
-
expect { instance.start }.to raise_error '[ node/test ] - Proxmox API call post nodes/pve_node_name/lxc/1024/status/start [] is constantly failing. Giving up.'
|
70
|
-
ensure
|
71
|
-
HybridPlatformsConductor::HpcPlugins::Provisioner::Proxmox.send(:remove_const, :RETRY_WAIT_TIME_SECS)
|
72
|
-
HybridPlatformsConductor::HpcPlugins::Provisioner::Proxmox.const_set(:RETRY_WAIT_TIME_SECS, old_wait_secs)
|
73
|
-
end
|
55
|
+
expect { instance.start }.to raise_error '[ node/test ] - Proxmox API call post nodes/pve_node_name/lxc/1024/status/start [] is constantly failing. Giving up.'
|
74
56
|
end
|
75
57
|
end
|
76
58
|
|
@@ -23,6 +23,32 @@ describe HybridPlatformsConductor::HpcPlugins::Provisioner::Proxmox do
|
|
23
23
|
end
|
24
24
|
end
|
25
25
|
|
26
|
+
it 'retries calls to the API when getting back errors 5xx' do
|
27
|
+
with_test_proxmox_platform do |instance|
|
28
|
+
mock_proxmox_calls_with [
|
29
|
+
# 1 - The info on existing containers
|
30
|
+
mock_proxmox_to_get_nodes_info,
|
31
|
+
# 2 - The status of the container
|
32
|
+
mock_proxmox_to_status_node(nbr_api_errors: 3)
|
33
|
+
]
|
34
|
+
instance.create
|
35
|
+
expect(instance.state).to eq :created
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'fails to get an instance\'s status when the Proxmox API fails too many times' do
|
40
|
+
with_test_proxmox_platform do |instance|
|
41
|
+
mock_proxmox_calls_with [
|
42
|
+
# 1 - The info on existing containers
|
43
|
+
mock_proxmox_to_get_nodes_info,
|
44
|
+
# 2 - The status of the container
|
45
|
+
mock_proxmox_to_status_node(nbr_api_errors: 4, status: nil)
|
46
|
+
]
|
47
|
+
instance.create
|
48
|
+
expect { instance.state }.to raise_error '[ node/test ] - Proxmox API call get nodes/pve_node_name/lxc returns NOK: error code = 500 continuously (tried 4 times)'
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
26
52
|
end
|
27
53
|
|
28
54
|
end
|
@@ -69,7 +69,7 @@ describe HybridPlatformsConductor::TestsRunner do
|
|
69
69
|
'node12' => { 'test_node12.sh' => proc { |stdout, stderr, exit_code| ssh_executions << ['node12', stdout, stderr, exit_code] } },
|
70
70
|
'node21' => { 'test_node21.sh' => proc { |stdout, stderr, exit_code| ssh_executions << ['node21', stdout, stderr, exit_code] } },
|
71
71
|
'node22' => { 'test_node22.sh' => proc { |stdout, stderr, exit_code| ssh_executions << ['node22', stdout, stderr, exit_code] } }
|
72
|
-
}}
|
72
|
+
} }
|
73
73
|
expect(test_tests_runner.run_tests([{ all: true }])).to eq 0
|
74
74
|
expect(ssh_executions.sort).to eq [
|
75
75
|
['node11', ['stdout11'], ['stderr11'], 0],
|
@@ -88,7 +88,7 @@ describe HybridPlatformsConductor::TestsRunner do
|
|
88
88
|
HybridPlatformsConductorTest::TestPlugins::NodeSsh.node_tests = { node_ssh_test: {
|
89
89
|
'node12' => { 'test_node12.sh' => proc { |stdout, stderr, exit_code| ssh_executions << ['node12', stdout, stderr, exit_code] } },
|
90
90
|
'node22' => { 'test_node22.sh' => proc { |stdout, stderr, exit_code| ssh_executions << ['node22', stdout, stderr, exit_code] } }
|
91
|
-
}}
|
91
|
+
} }
|
92
92
|
expect(test_tests_runner.run_tests(%w[node12 node22])).to eq 0
|
93
93
|
expect(ssh_executions.sort).to eq [
|
94
94
|
['node12', ['stdout12'], ['stderr12'], 0],
|
@@ -97,6 +97,19 @@ describe HybridPlatformsConductor::TestsRunner do
|
|
97
97
|
end
|
98
98
|
end
|
99
99
|
|
100
|
+
it 'does not execute anything when the tests report no command' do
|
101
|
+
with_test_platform_for_node_connection_tests do
|
102
|
+
test_tests_runner.tests = [:node_ssh_test]
|
103
|
+
ssh_executions = []
|
104
|
+
HybridPlatformsConductorTest::TestPlugins::NodeSsh.node_tests = { node_ssh_test: {
|
105
|
+
'node12' => {},
|
106
|
+
'node22' => {}
|
107
|
+
} }
|
108
|
+
expect(test_tests_runner.run_tests(%w[node12 node22])).to eq 0
|
109
|
+
expect(ssh_executions).to eq []
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
100
113
|
it 'executes several SSH node tests once per node with the correct command, grouping commands' do
|
101
114
|
with_test_platform_for_node_connection_tests do
|
102
115
|
expect_actions_executor_runs([proc do |actions|
|
@@ -54,13 +54,15 @@ module HybridPlatformsConductorTest
|
|
54
54
|
if with_session_exec
|
55
55
|
/^.+\/ssh #{with_batch_mode ? '-o BatchMode=yes ' : ''}-o ControlMaster=yes -o ControlPersist=yes hpc\.#{Regexp.escape(node)} true$/
|
56
56
|
else
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
57
|
+
unless ENV['hpc_interactive'] == 'false'
|
58
|
+
# Mock the user hitting enter as the Control Master will be created in another thread and the main thread waits for user input.
|
59
|
+
expect($stdin).to receive(:gets) do
|
60
|
+
# We have to wait for the Control Master creation thread to actually create the Control Master before hitting Enter.
|
61
|
+
while !control_master_created do
|
62
|
+
sleep 0.1
|
63
|
+
end
|
64
|
+
"\n"
|
62
65
|
end
|
63
|
-
"\n"
|
64
66
|
end
|
65
67
|
/^xterm -e '.+\/ssh -o ControlMaster=yes -o ControlPersist=yes hpc\.#{Regexp.escape(node)}'$/
|
66
68
|
end,
|
@@ -23,6 +23,8 @@ module HybridPlatformsConductorTest
|
|
23
23
|
test_platform path: '#{repository}'
|
24
24
|
proxmox(
|
25
25
|
api_url: 'https://my-proxmox.my-domain.com:8006',
|
26
|
+
api_max_retries: 3,
|
27
|
+
api_wait_between_retries_secs: 0,
|
26
28
|
sync_node: 'node',
|
27
29
|
test_config: {
|
28
30
|
pve_nodes: ['pve_node_name'],
|
@@ -75,12 +77,20 @@ module HybridPlatformsConductorTest
|
|
75
77
|
# * *proxmox_password* (String or nil): Proxmox password used to connect to Proxmox API [default: nil]
|
76
78
|
# * *proxmox_realm* (String or nil): Proxmox realm used to connect to Proxmox API [default: 'pam']
|
77
79
|
# * *nodes_info* (Array<Hash>): Nodes info returned by the Proxmox API [default: []]
|
80
|
+
# * *nbr_api_errors* (Integer): Number of API errors 500 to mock before getting a successful query [defaults: 0]
|
78
81
|
# * *extra_expects* (Proc or nil): Code called for additional expectations on the proxmox instance, or nil if none [default: nil]
|
79
82
|
# * Parameters::
|
80
83
|
# * *proxmox* (Double): The mocked Proxmox instance
|
81
84
|
# Result::
|
82
85
|
# * Proc: Code called in place of Proxmox.new. Signature is the same as Proxmox.new.
|
83
|
-
def mock_proxmox_to_get_nodes_info(
|
86
|
+
def mock_proxmox_to_get_nodes_info(
|
87
|
+
proxmox_user: nil,
|
88
|
+
proxmox_password: nil,
|
89
|
+
proxmox_realm: 'pam',
|
90
|
+
nodes_info: [],
|
91
|
+
nbr_api_errors: 0,
|
92
|
+
extra_expects: nil
|
93
|
+
)
|
84
94
|
proc do |url, pve_node, user, password, realm, options|
|
85
95
|
expect(url).to eq 'https://my-proxmox.my-domain.com:8006/api2/json/'
|
86
96
|
expect(pve_node).to eq 'my-proxmox'
|
@@ -97,8 +107,10 @@ module HybridPlatformsConductorTest
|
|
97
107
|
# Nothing
|
98
108
|
end
|
99
109
|
# Mock checking existing nodes
|
100
|
-
|
101
|
-
|
110
|
+
idx_try = 0
|
111
|
+
expect(proxmox).to receive(:get).exactly(nbr_api_errors + 1).times.with('nodes') do
|
112
|
+
idx_try += 1
|
113
|
+
idx_try <= nbr_api_errors ? 'NOK: error code = 500' : nodes_info
|
102
114
|
end
|
103
115
|
extra_expects.call(proxmox) unless extra_expects.nil?
|
104
116
|
proxmox
|
@@ -144,6 +156,7 @@ module HybridPlatformsConductorTest
|
|
144
156
|
idx_try += 1
|
145
157
|
idx_try <= nbr_api_errors ? 'NOK: error code = 500' : task_name
|
146
158
|
end
|
159
|
+
expect(proxmox).to receive(:reauthenticate).exactly(nbr_api_errors - (task_status.nil? ? 1 : 0)).times
|
147
160
|
# Mock checking task status
|
148
161
|
unless task_status.nil?
|
149
162
|
# Mock checking task status
|
@@ -243,13 +256,15 @@ module HybridPlatformsConductorTest
|
|
243
256
|
# Parameters::
|
244
257
|
# * *proxmox_user* (String or nil): Proxmox user used to connect to Proxmox API [default: nil]
|
245
258
|
# * *proxmox_password* (String or nil): Proxmox password used to connect to Proxmox API [default: nil]
|
246
|
-
# * *status* (String): Mocked status [default: 'created']
|
259
|
+
# * *status* (String or nil): Mocked status, or nil if it should not be asked [default: 'created']
|
260
|
+
# * *nbr_api_errors* (Integer): Number of API errors 500 to mock before getting a successful query [defaults: 0]
|
247
261
|
# Result::
|
248
262
|
# * Proc: Code called in place of Proxmox.new. Signature is the same as Proxmox.new.
|
249
263
|
def mock_proxmox_to_status_node(
|
250
264
|
proxmox_user: nil,
|
251
265
|
proxmox_password: nil,
|
252
|
-
|
266
|
+
status: 'created',
|
267
|
+
nbr_api_errors: 0
|
253
268
|
)
|
254
269
|
proc do |url, pve_node, user, password, realm, options|
|
255
270
|
expect(url).to eq 'https://my-proxmox.my-domain.com:8006/api2/json/'
|
@@ -267,17 +282,26 @@ module HybridPlatformsConductorTest
|
|
267
282
|
# Nothing
|
268
283
|
end
|
269
284
|
# Mock getting status of a container
|
270
|
-
|
271
|
-
|
285
|
+
idx_try = 0
|
286
|
+
expect(proxmox).to receive(:get).exactly(nbr_api_errors + (status.nil? ? 0 : 1)).times.with('nodes/pve_node_name/lxc') do
|
287
|
+
idx_try += 1
|
288
|
+
if idx_try <= nbr_api_errors
|
289
|
+
'NOK: error code = 500'
|
290
|
+
else
|
291
|
+
[
|
292
|
+
{
|
293
|
+
'vmid' => '1024'
|
294
|
+
}
|
295
|
+
]
|
296
|
+
end
|
297
|
+
end
|
298
|
+
expect(proxmox).to receive(:reauthenticate).exactly(nbr_api_errors - (status.nil? ? 1 : 0)).times
|
299
|
+
unless status.nil?
|
300
|
+
expect(proxmox).to receive(:get).with('nodes/pve_node_name/lxc/1024/status/current') do
|
272
301
|
{
|
273
|
-
'
|
302
|
+
'status' => status
|
274
303
|
}
|
275
|
-
|
276
|
-
end
|
277
|
-
expect(proxmox).to receive(:get).with('nodes/pve_node_name/lxc/1024/status/current') do
|
278
|
-
{
|
279
|
-
'status' => 'created'
|
280
|
-
}
|
304
|
+
end
|
281
305
|
end
|
282
306
|
proxmox
|
283
307
|
end
|
@@ -548,13 +572,17 @@ module HybridPlatformsConductorTest
|
|
548
572
|
]
|
549
573
|
when /^nodes\/([^\/]+)\/lxc$/
|
550
574
|
pve_node_name = $1
|
551
|
-
pve_nodes[pve_node_name][:
|
552
|
-
|
553
|
-
|
554
|
-
|
555
|
-
|
556
|
-
|
557
|
-
|
575
|
+
if pve_nodes[pve_node_name][:error_strings].nil? || pve_nodes[pve_node_name][:error_strings].empty?
|
576
|
+
pve_nodes[pve_node_name][:lxc_containers].map do |vm_id, vm_info|
|
577
|
+
{
|
578
|
+
'vmid' => vm_id.to_s,
|
579
|
+
'maxdisk' => vm_info[:maxdisk],
|
580
|
+
'maxmem' => vm_info[:maxmem],
|
581
|
+
'cpus' => vm_info[:cpus]
|
582
|
+
}
|
583
|
+
end
|
584
|
+
else
|
585
|
+
pve_nodes[pve_node_name][:error_strings].shift
|
558
586
|
end
|
559
587
|
when /^nodes\/([^\/]+)\/lxc\/([^\/]+)\/config$/
|
560
588
|
pve_node_name = $1
|
@@ -615,6 +643,10 @@ module HybridPlatformsConductorTest
|
|
615
643
|
raise "Unknown Proxmox API post call: #{path}. Please adapt the test framework."
|
616
644
|
end
|
617
645
|
end
|
646
|
+
# Mock create_ticket
|
647
|
+
allow(proxmox).to receive(:create_ticket) do
|
648
|
+
@proxmox_actions << [:create_ticket]
|
649
|
+
end
|
618
650
|
proxmox
|
619
651
|
end
|
620
652
|
end,
|
@@ -642,14 +674,26 @@ module HybridPlatformsConductorTest
|
|
642
674
|
# * *wait_before_retry* (Integer): Specify the number of seconds to wait before retry [default: 0]
|
643
675
|
# * *create* (Hash or nil): Create file content, or nil if none [default: nil]
|
644
676
|
# * *destroy* (Hash or nil): Destroy file content, or nil if none [default: nil]
|
677
|
+
# * *api_max_retries* (Integer): Max number of API retries [default: 3]
|
678
|
+
# * *api_wait_between_retries_secs* (Integer): Number of seconds to wait between API retries [default: 0]
|
645
679
|
# Result::
|
646
680
|
# * Hash: JSON result of the call
|
647
|
-
def call_reserve_proxmox_container_with(
|
681
|
+
def call_reserve_proxmox_container_with(
|
682
|
+
config: {},
|
683
|
+
max_retries: 1,
|
684
|
+
wait_before_retry: 0,
|
685
|
+
create: nil,
|
686
|
+
destroy: nil,
|
687
|
+
api_max_retries: 3,
|
688
|
+
api_wait_between_retries_secs: 0
|
689
|
+
)
|
648
690
|
# Make sure we set default values in the config
|
649
691
|
config = {
|
650
692
|
proxmox_api_url: 'https://my-proxmox.my-domain.com:8006',
|
651
693
|
futex_file: "#{@repository}/proxmox/allocations.futex",
|
652
694
|
logs_dir: "#{Dir.tmpdir}/hpc_test_proxmox_waiter_logs",
|
695
|
+
api_max_retries: api_max_retries,
|
696
|
+
api_wait_between_retries_secs: api_wait_between_retries_secs,
|
653
697
|
pve_nodes: ['pve_node_name'],
|
654
698
|
vm_ips_list: %w[
|
655
699
|
192.168.0.100
|
@@ -716,7 +760,14 @@ module HybridPlatformsConductorTest
|
|
716
760
|
# * *wait_before_retry* (Integer): Specify the number of seconds to wait before retry [default: 0]
|
717
761
|
# Result::
|
718
762
|
# * Hash: JSON result of the call
|
719
|
-
def call_reserve_proxmox_container(
|
763
|
+
def call_reserve_proxmox_container(
|
764
|
+
cpus,
|
765
|
+
ram_mb,
|
766
|
+
disk_gb,
|
767
|
+
config: {},
|
768
|
+
max_retries: 1,
|
769
|
+
wait_before_retry: 0
|
770
|
+
)
|
720
771
|
call_reserve_proxmox_container_with(
|
721
772
|
config: config,
|
722
773
|
max_retries: max_retries,
|
@@ -763,7 +814,13 @@ module HybridPlatformsConductorTest
|
|
763
814
|
# Parameters::
|
764
815
|
# * *expected_proxmox_actions* (Array<Array>): Expected Proxmox actions
|
765
816
|
def expect_proxmox_actions_to_be(expected_proxmox_actions)
|
766
|
-
expect(@proxmox_actions.size).to eq
|
817
|
+
expect(@proxmox_actions.size).to eq(expected_proxmox_actions.size), <<~EOS
|
818
|
+
Expected #{expected_proxmox_actions.size} Proxmox actions, but got #{@proxmox_actions.size} instead:
|
819
|
+
----- Received:
|
820
|
+
#{@proxmox_actions.map(&:inspect).join("\n")}
|
821
|
+
----- Expected:
|
822
|
+
#{expected_proxmox_actions.map(&:inspect).join("\n")}
|
823
|
+
EOS
|
767
824
|
@proxmox_actions.zip(expected_proxmox_actions).each do |proxmox_action, expected_proxmox_action|
|
768
825
|
expect(proxmox_action.size).to eq expected_proxmox_action.size
|
769
826
|
expect(proxmox_action[0..1]).to eq expected_proxmox_action[0..1]
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: hybrid_platforms_conductor
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 32.
|
4
|
+
version: 32.9.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Muriel Salvan
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2021-03-
|
11
|
+
date: 2021-03-23 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: range_operators
|