beaker 1.9.1 → 1.10.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +8 -8
- data/.simplecov +1 -0
- data/beaker.gemspec +2 -0
- data/lib/beaker/dsl/helpers.rb +15 -6
- data/lib/beaker/dsl/install_utils.rb +1 -1
- data/lib/beaker/host.rb +21 -2
- data/lib/beaker/host/aix/exec.rb +7 -0
- data/lib/beaker/host/unix.rb +4 -0
- data/lib/beaker/host/unix/exec.rb +8 -0
- data/lib/beaker/host/windows.rb +2 -0
- data/lib/beaker/host/windows/exec.rb +11 -0
- data/lib/beaker/host_prebuilt_steps.rb +7 -6
- data/lib/beaker/hypervisor.rb +6 -2
- data/lib/beaker/hypervisor/aws_sdk.rb +389 -0
- data/lib/beaker/hypervisor/blimper.rb +8 -23
- data/lib/beaker/hypervisor/ec2_helper.rb +28 -0
- data/lib/beaker/hypervisor/google_compute.rb +10 -8
- data/lib/beaker/hypervisor/google_compute_helper.rb +1 -1
- data/lib/beaker/hypervisor/vagrant.rb +8 -0
- data/lib/beaker/options/command_line_parser.rb +6 -0
- data/lib/beaker/options/parser.rb +1 -1
- data/lib/beaker/options/presets.rb +3 -0
- data/lib/beaker/ssh_connection.rb +2 -3
- data/lib/beaker/test_case.rb +2 -2
- data/lib/beaker/version.rb +1 -1
- data/spec/beaker/dsl/helpers_spec.rb +27 -5
- data/spec/beaker/dsl/install_utils_spec.rb +2 -2
- data/spec/beaker/host_prebuilt_steps_spec.rb +2 -2
- data/spec/beaker/hypervisor/aws_sdk_spec.rb +81 -0
- data/spec/beaker/hypervisor/blimper_spec.rb +0 -35
- data/spec/beaker/hypervisor/ec2_helper_spec.rb +23 -0
- data/spec/beaker/hypervisor/vagrant_spec.rb +22 -1
- data/spec/beaker/options/parser_spec.rb +4 -1
- data/spec/beaker/test_case_spec.rb +85 -1
- data/spec/mocks.rb +4 -0
- metadata +37 -2
@@ -1,29 +1,10 @@
|
|
1
1
|
require 'blimpy'
|
2
2
|
require 'yaml' unless defined?(YAML)
|
3
|
+
require 'beaker/hypervisor/ec2_helper'
|
3
4
|
|
4
|
-
module Beaker
|
5
|
+
module Beaker
|
5
6
|
class Blimper < Beaker::Hypervisor
|
6
7
|
|
7
|
-
def amiports(host)
|
8
|
-
roles = host['roles']
|
9
|
-
ports = [22]
|
10
|
-
|
11
|
-
if roles.include? 'database'
|
12
|
-
ports << 8080
|
13
|
-
ports << 8081
|
14
|
-
end
|
15
|
-
|
16
|
-
if roles.include? 'master'
|
17
|
-
ports << 8140
|
18
|
-
end
|
19
|
-
|
20
|
-
if roles.include? 'dashboard'
|
21
|
-
ports << 443
|
22
|
-
end
|
23
|
-
|
24
|
-
ports
|
25
|
-
end
|
26
|
-
|
27
8
|
def initialize(blimpy_hosts, options)
|
28
9
|
@options = options
|
29
10
|
@logger = options[:logger]
|
@@ -46,7 +27,7 @@ module Beaker
|
|
46
27
|
ami = ami_spec[amitype]
|
47
28
|
fleet.add(:aws) do |ship|
|
48
29
|
ship.name = host.name
|
49
|
-
ship.ports = amiports(host)
|
30
|
+
ship.ports = Beaker::EC2Helper.amiports(host['roles'])
|
50
31
|
ship.image_id = ami[:image][image_type.to_sym]
|
51
32
|
if not ship.image_id
|
52
33
|
raise "No image_id found for host #{ship.name} (#{amitype}:#{amisize}) using snapshot/image_type #{image_type}"
|
@@ -54,7 +35,11 @@ module Beaker
|
|
54
35
|
ship.flavor = amisize
|
55
36
|
ship.region = ami[:region]
|
56
37
|
ship.username = 'root'
|
57
|
-
ship.tags = {
|
38
|
+
ship.tags = {
|
39
|
+
:department => @options[:department],
|
40
|
+
:project => @options[:project],
|
41
|
+
:jenkins_build_url => @options[:jenkins_build_url],
|
42
|
+
}
|
58
43
|
end
|
59
44
|
@logger.debug "Added #{host.name} (#{amitype}:#{amisize}) using snapshot/image_type #{image_type} to blimpy fleet"
|
60
45
|
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module Beaker
|
2
|
+
class EC2Helper
|
3
|
+
# Return a list of open ports for testing based on a hosts role
|
4
|
+
#
|
5
|
+
# @todo horribly hard-coded
|
6
|
+
# @param [Array<String>] an array of roles
|
7
|
+
# @return [Array<Number>] array of port numbers
|
8
|
+
# @api private
|
9
|
+
def self.amiports(roles)
|
10
|
+
ports = [22]
|
11
|
+
|
12
|
+
if roles.include? 'database'
|
13
|
+
ports << 8080
|
14
|
+
ports << 8081
|
15
|
+
end
|
16
|
+
|
17
|
+
if roles.include? 'master'
|
18
|
+
ports << 8140
|
19
|
+
end
|
20
|
+
|
21
|
+
if roles.include? 'dashboard'
|
22
|
+
ports << 443
|
23
|
+
end
|
24
|
+
|
25
|
+
ports
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -45,7 +45,8 @@ module Beaker
|
|
45
45
|
|
46
46
|
|
47
47
|
@hosts.each do |host|
|
48
|
-
|
48
|
+
gplatform = Platform.new(host[:image] || host[:platform])
|
49
|
+
img = @gce_helper.get_latest_image(gplatform, start, attempts)
|
49
50
|
host['diskname'] = generate_host_name
|
50
51
|
disk = @gce_helper.create_disk(host['diskname'], img, start, attempts)
|
51
52
|
@logger.debug("Created Google Compute disk for #{host.name}: #{host['diskname']}")
|
@@ -57,8 +58,9 @@ module Beaker
|
|
57
58
|
@logger.debug("Created Google Compute instance for #{host.name}: #{host['vmhostname']}")
|
58
59
|
#add metadata to instance
|
59
60
|
@gce_helper.setMetadata_on_instance(host['vmhostname'], instance['metadata']['fingerprint'],
|
60
|
-
[ {:key => :department, :value => @options[:department]},
|
61
|
-
{:key => :project, :value => @options[:project]}
|
61
|
+
[ {:key => :department, :value => @options[:department]},
|
62
|
+
{:key => :project, :value => @options[:project]},
|
63
|
+
{:key => :jenkins_build_url, :value => @options[:jenkins_build_url]} ],
|
62
64
|
start, attempts)
|
63
65
|
@logger.debug("Added tags to Google Compute instance #{host.name}: #{host['vmhostname']}")
|
64
66
|
|
@@ -87,7 +89,7 @@ module Beaker
|
|
87
89
|
attempts = @options[:timeout].to_i / SLEEPWAIT
|
88
90
|
start = Time.now
|
89
91
|
|
90
|
-
@gce_helper.delete_firewall(@firewall, start, attempts)
|
92
|
+
@gce_helper.delete_firewall(@firewall, start, attempts)
|
91
93
|
|
92
94
|
@hosts.each do |host|
|
93
95
|
@gce_helper.delete_instance(host['vmhostname'], start, attempts)
|
@@ -97,14 +99,14 @@ module Beaker
|
|
97
99
|
end
|
98
100
|
|
99
101
|
end
|
100
|
-
|
101
|
-
#Shutdown and destroy Google Compute instances (including their associated disks and firewall rules)
|
102
|
+
|
103
|
+
#Shutdown and destroy Google Compute instances (including their associated disks and firewall rules)
|
102
104
|
#that have been alive longer than ZOMBIE hours.
|
103
105
|
def kill_zombies(max_age = ZOMBIE)
|
104
106
|
now = start = Time.now
|
105
107
|
attempts = @options[:timeout].to_i / SLEEPWAIT
|
106
108
|
|
107
|
-
#get rid of old instances
|
109
|
+
#get rid of old instances
|
108
110
|
instances = @gce_helper.list_instances(start, attempts)
|
109
111
|
if instances
|
110
112
|
instances.each do |instance|
|
@@ -116,7 +118,7 @@ module Beaker
|
|
116
118
|
@gce_helper.delete_instance( instance['name'], start, attempts )
|
117
119
|
end
|
118
120
|
end
|
119
|
-
else
|
121
|
+
else
|
120
122
|
@logger.debug("No zombie instances found")
|
121
123
|
end
|
122
124
|
#get rid of old disks
|
@@ -477,7 +477,7 @@ module Beaker
|
|
477
477
|
{ :api_method => @compute.firewalls.insert,
|
478
478
|
:parameters => { 'project' => @options[:gce_project], 'zone' => DEFAULT_ZONE_NAME },
|
479
479
|
:body_object => { 'name' => name,
|
480
|
-
'allowed'=> [ { 'IPProtocol' => 'tcp', "ports" => [ '443', '8140', '61613' ]} ],
|
480
|
+
'allowed'=> [ { 'IPProtocol' => 'tcp', "ports" => [ '443', '8140', '61613', '8080', '8081' ]} ],
|
481
481
|
'network'=> network,
|
482
482
|
'sourceRanges' => [ "0.0.0.0/0" ] } }
|
483
483
|
end
|
@@ -30,6 +30,11 @@ module Beaker
|
|
30
30
|
v_file << " v.vm.box_url = '#{host['box_url']}'\n" unless host['box_url'].nil?
|
31
31
|
v_file << " v.vm.base_mac = '#{randmac}'\n"
|
32
32
|
v_file << " v.vm.network :private_network, ip: \"#{host['ip'].to_s}\", :netmask => \"#{host['netmask'] ||= "255.255.0.0"}\"\n"
|
33
|
+
if /windows/i.match(host['platform'])
|
34
|
+
v_file << " v.vm.network :forwarded_port, guest: 3389, host: 3389\n"
|
35
|
+
v_file << " v.vm.network :forwarded_port, guest: 5985, host: 5985, id: 'winrm', auto_correct: true\n"
|
36
|
+
v_file << " v.vm.guest = :windows"
|
37
|
+
end
|
33
38
|
v_file << " end\n"
|
34
39
|
@logger.debug "created Vagrantfile for VagrantHost #{host.name}"
|
35
40
|
end
|
@@ -92,6 +97,9 @@ module Beaker
|
|
92
97
|
end
|
93
98
|
|
94
99
|
def provision
|
100
|
+
if !@options[:provision] and !File.file?(@vagrant_file)
|
101
|
+
raise "Beaker is configured with provision = false but no vagrant file was found at #{@vagrant_file}. You need to enable provision"
|
102
|
+
end
|
95
103
|
if @options[:provision]
|
96
104
|
#setting up new vagrant hosts
|
97
105
|
#make sure that any old boxes are dead dead dead
|
@@ -170,6 +170,12 @@ module Beaker
|
|
170
170
|
@cmd_options[:add_el_extras] = true
|
171
171
|
end
|
172
172
|
|
173
|
+
opts.on '--[no-]validate',
|
174
|
+
'Validate that SUTs are correctly configured before running tests',
|
175
|
+
'(default: true)' do |bool|
|
176
|
+
@cmd_options[:validate] = bool
|
177
|
+
end
|
178
|
+
|
173
179
|
opts.on('--version', 'Report currently running version of beaker' ) do
|
174
180
|
@cmd_options[:version] = true
|
175
181
|
end
|
@@ -78,7 +78,7 @@ module Beaker
|
|
78
78
|
if discover_files.empty?
|
79
79
|
parser_error "empty directory used as an option (#{root})!"
|
80
80
|
end
|
81
|
-
files += discover_files.
|
81
|
+
files += discover_files.sort_by {|file| [file.count("/"), file]}
|
82
82
|
else #not a file, not a directory, not nothin'
|
83
83
|
parser_error "#{root} used as a file option but is not a file or directory!"
|
84
84
|
end
|
@@ -23,6 +23,7 @@ module Beaker
|
|
23
23
|
:pe_ver => ENV['pe_ver'],
|
24
24
|
:project => ENV['BEAKER_project'],
|
25
25
|
:department => ENV['BEAKER_department'],
|
26
|
+
:jenkins_build_url => ENV['BUILD_URL'],
|
26
27
|
}.delete_if {|key, value| value.nil? or value.empty? })
|
27
28
|
end
|
28
29
|
|
@@ -34,6 +35,8 @@ module Beaker
|
|
34
35
|
h.merge({
|
35
36
|
:project => 'Beaker',
|
36
37
|
:department => ENV['USER'] || ENV['USERNAME'] || 'unknown',
|
38
|
+
:validate => true,
|
39
|
+
:jenkins_build_url => nil,
|
37
40
|
:log_level => 'verbose',
|
38
41
|
:trace_limit => 10,
|
39
42
|
:hosts_file => 'sample.cfg',
|
@@ -14,7 +14,8 @@ module Beaker
|
|
14
14
|
Errno::ECONNREFUSED,
|
15
15
|
Errno::ECONNRESET,
|
16
16
|
Errno::ENETUNREACH,
|
17
|
-
Net::SSH::Disconnect
|
17
|
+
Net::SSH::Disconnect,
|
18
|
+
Net::SSH::AuthenticationFailed,
|
18
19
|
]
|
19
20
|
|
20
21
|
def initialize hostname, user = nil, options = {}
|
@@ -48,8 +49,6 @@ module Beaker
|
|
48
49
|
puts "Failed to connect to #{@hostname}"
|
49
50
|
raise
|
50
51
|
end
|
51
|
-
rescue Net::SSH::AuthenticationFailed => e
|
52
|
-
raise "Unable to authenticate to #{@hostname} with user #{e.message.inspect}"
|
53
52
|
end
|
54
53
|
self
|
55
54
|
end
|
data/lib/beaker/test_case.rb
CHANGED
@@ -131,13 +131,13 @@ module Beaker
|
|
131
131
|
@test_status = :pending
|
132
132
|
rescue SkipTest
|
133
133
|
@test_status = :skip
|
134
|
-
rescue StandardError, ScriptError => e
|
134
|
+
rescue StandardError, ScriptError, SignalException => e
|
135
135
|
log_and_fail_test(e)
|
136
136
|
ensure
|
137
137
|
@teardown_procs.each do |teardown|
|
138
138
|
begin
|
139
139
|
teardown.call
|
140
|
-
rescue StandardError => e
|
140
|
+
rescue StandardError, SignalException => e
|
141
141
|
log_and_fail_test(e)
|
142
142
|
end
|
143
143
|
end
|
data/lib/beaker/version.rb
CHANGED
@@ -447,6 +447,26 @@ describe ClassMixedWithDSLHelpers do
|
|
447
447
|
:expect_failures => true
|
448
448
|
)
|
449
449
|
end
|
450
|
+
|
451
|
+
it 'can set the --parser future flag' do
|
452
|
+
subject.should_receive( :create_remote_file ).and_return( true )
|
453
|
+
subject.should_receive( :puppet ).
|
454
|
+
with( 'apply', '--verbose', '--parser future', '--detailed-exitcodes', 'agent' ).
|
455
|
+
and_return( 'puppet_command' )
|
456
|
+
subject.should_receive( :on ).with(
|
457
|
+
agent,
|
458
|
+
'puppet_command',
|
459
|
+
:acceptable_exit_codes => [1,2,3,4,5,6]
|
460
|
+
)
|
461
|
+
|
462
|
+
subject.apply_manifest_on(
|
463
|
+
agent,
|
464
|
+
'class { "boo": }',
|
465
|
+
:acceptable_exit_codes => (1..5),
|
466
|
+
:future_parser => true,
|
467
|
+
:expect_failures => true
|
468
|
+
)
|
469
|
+
end
|
450
470
|
end
|
451
471
|
|
452
472
|
describe "#apply_manifest" do
|
@@ -654,11 +674,13 @@ describe ClassMixedWithDSLHelpers do
|
|
654
674
|
let(:test_case_path) { 'testcase/path' }
|
655
675
|
let(:tmpdir_path) { '/tmp/tmpdir' }
|
656
676
|
let(:puppet_path) { '/puppet/path' }
|
677
|
+
let(:puppetservice) { @ps || nil }
|
657
678
|
let(:is_pe) { false }
|
658
679
|
let(:host) do
|
659
680
|
FakeHost.new(:pe => is_pe,
|
660
681
|
:options => {
|
661
682
|
'puppetpath' => puppet_path,
|
683
|
+
'puppetservice' => puppetservice,
|
662
684
|
'platform' => 'el'
|
663
685
|
})
|
664
686
|
end
|
@@ -689,23 +711,23 @@ describe ClassMixedWithDSLHelpers do
|
|
689
711
|
Tempfile.should_receive(:open).with('beaker')
|
690
712
|
end
|
691
713
|
|
692
|
-
context '
|
693
|
-
let(:
|
714
|
+
context 'with puppetservice and service-path defined' do
|
715
|
+
let(:puppetservice) { 'whatever' }
|
694
716
|
|
695
717
|
it 'bounces puppet twice' do
|
696
718
|
subject.with_puppet_running_on(host, {})
|
697
|
-
expect(host).to execute_commands_matching(
|
719
|
+
expect(host).to execute_commands_matching(/#{@ps} restart/).exactly(2).times
|
698
720
|
end
|
699
721
|
|
700
722
|
it 'yield to a block after bouncing service' do
|
701
723
|
execution = 0
|
702
724
|
expect do
|
703
725
|
subject.with_puppet_running_on(host, {}) do
|
704
|
-
expect(host).to execute_commands_matching(
|
726
|
+
expect(host).to execute_commands_matching(/#{@ps} restart/).once
|
705
727
|
execution += 1
|
706
728
|
end
|
707
729
|
end.to change { execution }.by(1)
|
708
|
-
expect(host).to execute_commands_matching(
|
730
|
+
expect(host).to execute_commands_matching(/#{@ps} restart/).exactly(2).times
|
709
731
|
end
|
710
732
|
end
|
711
733
|
|
@@ -299,7 +299,7 @@ describe ClassMixedWithDSLInstallUtils do
|
|
299
299
|
it 'installs' do
|
300
300
|
expect(subject).to receive(:on).with(hosts[0], /puppetlabs-release-\$\(lsb_release -c -s\)\.deb/)
|
301
301
|
expect(subject).to receive(:on).with(hosts[0], 'dpkg -i puppetlabs-release-$(lsb_release -c -s).deb')
|
302
|
-
expect(subject).to receive(:on).with(hosts[0], 'apt-get
|
302
|
+
expect(subject).to receive(:on).with(hosts[0], 'apt-get update')
|
303
303
|
expect(subject).to receive(:on).with(hosts[0], 'apt-get install -y puppet')
|
304
304
|
subject.install_puppet
|
305
305
|
end
|
@@ -309,7 +309,7 @@ describe ClassMixedWithDSLInstallUtils do
|
|
309
309
|
it 'installs' do
|
310
310
|
expect(subject).to receive(:on).with(hosts[0], /puppetlabs-release-\$\(lsb_release -c -s\)\.deb/)
|
311
311
|
expect(subject).to receive(:on).with(hosts[0], 'dpkg -i puppetlabs-release-$(lsb_release -c -s).deb')
|
312
|
-
expect(subject).to receive(:on).with(hosts[0], 'apt-get
|
312
|
+
expect(subject).to receive(:on).with(hosts[0], 'apt-get update')
|
313
313
|
expect(subject).to receive(:on).with(hosts[0], 'apt-get install -y puppet')
|
314
314
|
subject.install_puppet
|
315
315
|
end
|
@@ -111,7 +111,7 @@ describe Beaker do
|
|
111
111
|
it "can perform apt-get on ubuntu hosts" do
|
112
112
|
host = make_host( 'testhost', { :platform => 'ubuntu' } )
|
113
113
|
|
114
|
-
Beaker::Command.should_receive( :new ).with("apt-get
|
114
|
+
Beaker::Command.should_receive( :new ).with("apt-get update").once
|
115
115
|
|
116
116
|
subject.apt_get_update( host )
|
117
117
|
|
@@ -120,7 +120,7 @@ describe Beaker do
|
|
120
120
|
it "can perform apt-get on debian hosts" do
|
121
121
|
host = make_host( 'testhost', { :platform => 'debian' } )
|
122
122
|
|
123
|
-
Beaker::Command.should_receive( :new ).with("apt-get
|
123
|
+
Beaker::Command.should_receive( :new ).with("apt-get update").once
|
124
124
|
|
125
125
|
subject.apt_get_update( host )
|
126
126
|
|
@@ -0,0 +1,81 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module Beaker
|
4
|
+
describe AwsSdk do
|
5
|
+
let(:aws) {
|
6
|
+
# Mock out the call to load_fog_credentials
|
7
|
+
Beaker::AwsSdk.any_instance.stub(:load_fog_credentials).and_return(fog_file_contents)
|
8
|
+
|
9
|
+
# This is needed because the EC2 api looks up a local endpoints.json file
|
10
|
+
FakeFS.deactivate!
|
11
|
+
aws = Beaker::AwsSdk.new(@hosts, make_opts)
|
12
|
+
FakeFS.activate!
|
13
|
+
|
14
|
+
aws
|
15
|
+
}
|
16
|
+
let(:amispec) {{
|
17
|
+
"centos-5-x86-64-west" => {
|
18
|
+
:image => {:pe => "ami-sekrit1"},
|
19
|
+
:region => "us-west-2",
|
20
|
+
},
|
21
|
+
"centos-6-x86-64-west" => {
|
22
|
+
:image => {:pe => "ami-sekrit2"},
|
23
|
+
:region => "us-west-2",
|
24
|
+
},
|
25
|
+
"centos-7-x86-64-west" => {
|
26
|
+
:image => {:pe => "ami-sekrit3"},
|
27
|
+
:region => "us-west-2",
|
28
|
+
},
|
29
|
+
}}
|
30
|
+
|
31
|
+
before :each do
|
32
|
+
@hosts = make_hosts({:snapshot => :pe})
|
33
|
+
@hosts[0][:platform] = "centos-5-x86-64-west"
|
34
|
+
@hosts[1][:platform] = "centos-6-x86-64-west"
|
35
|
+
@hosts[2][:platform] = "centos-7-x86-64-west"
|
36
|
+
end
|
37
|
+
|
38
|
+
context '#backoff_sleep' do
|
39
|
+
it "should call sleep 1024 times at attempt 10" do
|
40
|
+
Object.any_instance.should_receive(:sleep).with(1024)
|
41
|
+
aws.backoff_sleep(10)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
context '#public_key' do
|
46
|
+
it "retrieves contents from local ~/.ssh/ssh_rsa.pub file" do
|
47
|
+
# Stub calls to file read/exists
|
48
|
+
allow(File).to receive(:exists?).with(/id_rsa.pub/) { true }
|
49
|
+
allow(File).to receive(:read).with(/id_rsa.pub/) { "foobar" }
|
50
|
+
|
51
|
+
# Should return contents of previously stubbed id_rsa.pub
|
52
|
+
expect(aws.public_key).to eq("foobar")
|
53
|
+
end
|
54
|
+
|
55
|
+
it "should return an error if the files do not exist" do
|
56
|
+
expect { aws.public_key }.to raise_error(RuntimeError, /Expected either/)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
context '#key_name' do
|
61
|
+
it 'returns a key name from the local hostname' do
|
62
|
+
# Mock out the hostname and local user calls
|
63
|
+
Socket.should_receive(:gethostname) { "foobar" }
|
64
|
+
aws.should_receive(:local_user) { "bob" }
|
65
|
+
|
66
|
+
# Should match the expected composite key name
|
67
|
+
expect(aws.key_name).to eq("Beaker-bob-foobar")
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
context '#group_id' do
|
72
|
+
it 'should return a predicatable group_id from a port list' do
|
73
|
+
expect(aws.group_id([22, 1024])).to eq("Beaker-2799478787")
|
74
|
+
end
|
75
|
+
|
76
|
+
it 'should return a predicatable group_id from an empty list' do
|
77
|
+
expect { aws.group_id([]) }.to raise_error(ArgumentError, "Ports list cannot be nil or empty")
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|