knife-azure 1.1.4 → 1.2.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 +15 -0
- data/README.md +37 -0
- data/lib/azure/ag.rb +97 -0
- data/lib/azure/connection.rb +25 -7
- data/lib/azure/deploy.rb +8 -4
- data/lib/azure/host.rb +7 -4
- data/lib/azure/rest.rb +32 -8
- data/lib/azure/role.rb +85 -55
- data/lib/azure/storageaccount.rb +4 -2
- data/lib/azure/utility.rb +12 -0
- data/lib/azure/vnet.rb +89 -0
- data/lib/chef/knife/azure_ag_create.rb +76 -0
- data/lib/chef/knife/azure_ag_list.rb +51 -0
- data/lib/chef/knife/azure_base.rb +9 -0
- data/lib/chef/knife/azure_server_create.rb +61 -31
- data/lib/chef/knife/azure_server_delete.rb +8 -1
- data/lib/chef/knife/{azure_server_describe.rb → azure_server_show.rb} +22 -14
- data/lib/chef/knife/azure_vnet_create.rb +77 -0
- data/lib/chef/knife/azure_vnet_list.rb +52 -0
- data/lib/knife-azure/version.rb +1 -1
- metadata +28 -43
data/lib/azure/storageaccount.rb
CHANGED
@@ -18,6 +18,7 @@
|
|
18
18
|
|
19
19
|
class Azure
|
20
20
|
class StorageAccounts
|
21
|
+
include AzureUtility
|
21
22
|
def initialize(connection)
|
22
23
|
@connection=connection
|
23
24
|
end
|
@@ -52,8 +53,9 @@ class Azure
|
|
52
53
|
# Look up on cloud and not local cache
|
53
54
|
def exists_on_cloud?(name)
|
54
55
|
ret_val = @connection.query_azure("storageservices/#{name}")
|
55
|
-
|
56
|
-
|
56
|
+
error_code, error_message = error_from_response_xml(ret_val) if ret_val
|
57
|
+
if ret_val.nil? || error_code.length > 0
|
58
|
+
Chef::Log.warn 'Unable to find storage account:' + error_message + ' : ' + error_message if ret_val
|
57
59
|
false
|
58
60
|
else
|
59
61
|
true
|
data/lib/azure/utility.rb
CHANGED
@@ -25,5 +25,17 @@ module AzureUtility
|
|
25
25
|
end
|
26
26
|
content
|
27
27
|
end
|
28
|
+
|
29
|
+
def error_from_response_xml(response_xml)
|
30
|
+
error_code_and_message = ['','']
|
31
|
+
error_node = response_xml.at_css('Error')
|
32
|
+
|
33
|
+
if error_node
|
34
|
+
error_code_and_message[0] = xml_content(error_node, 'Code')
|
35
|
+
error_code_and_message[1] = xml_content(error_node, 'Message')
|
36
|
+
end
|
37
|
+
|
38
|
+
error_code_and_message
|
39
|
+
end
|
28
40
|
end
|
29
41
|
|
data/lib/azure/vnet.rb
ADDED
@@ -0,0 +1,89 @@
|
|
1
|
+
#
|
2
|
+
# Author:: Jeff Mendoza (jeffmendoza@live.com)
|
3
|
+
# Copyright:: Copyright (c) 2013 Opscode, Inc.
|
4
|
+
# License:: Apache License, Version 2.0
|
5
|
+
#
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
7
|
+
# you may not use this file except in compliance with the License.
|
8
|
+
# You may obtain a copy of the License at
|
9
|
+
#
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
11
|
+
#
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
14
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
15
|
+
# See the License for the specific language governing permissions and
|
16
|
+
# limitations under the License.
|
17
|
+
#
|
18
|
+
|
19
|
+
class Azure
|
20
|
+
class Vnets
|
21
|
+
def initialize(connection)
|
22
|
+
@connection = connection
|
23
|
+
end
|
24
|
+
|
25
|
+
def load
|
26
|
+
@vnets ||= begin
|
27
|
+
@vnets = {}
|
28
|
+
response = @connection.query_azure('networking/virtualnetwork')
|
29
|
+
response.css('VirtualNetworkSite').each do |vnet|
|
30
|
+
item = Vnet.new(@connection).parse(vnet)
|
31
|
+
@vnets[item.name] = item
|
32
|
+
end
|
33
|
+
@vnets
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def all
|
38
|
+
load.values
|
39
|
+
end
|
40
|
+
|
41
|
+
def exists?(name)
|
42
|
+
load.key?(name)
|
43
|
+
end
|
44
|
+
|
45
|
+
def find(name)
|
46
|
+
load[name]
|
47
|
+
end
|
48
|
+
|
49
|
+
def create(params)
|
50
|
+
ag = Vnet.new(@connection)
|
51
|
+
ag.create(params)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
class Azure
|
57
|
+
class Vnet
|
58
|
+
attr_accessor :name, :affinity_group, :state
|
59
|
+
|
60
|
+
def initialize(connection)
|
61
|
+
@connection = connection
|
62
|
+
end
|
63
|
+
|
64
|
+
def parse(image)
|
65
|
+
@name = image.at_css('Name').content
|
66
|
+
@affinity_group = image.at_css('AffinityGroup').content
|
67
|
+
@state = image.at_css('State').content
|
68
|
+
self
|
69
|
+
end
|
70
|
+
|
71
|
+
def create(params)
|
72
|
+
response = @connection.query_azure('networking/media')
|
73
|
+
vnets = response.css('VirtualNetworkSite')
|
74
|
+
vnet = nil
|
75
|
+
vnets.each { |vn| vnet = vn if vn['name'] == params[:azure_vnet_name] }
|
76
|
+
add = vnet.nil?
|
77
|
+
vnet = Nokogiri::XML::Node.new('VirtualNetworkSite', response) if add
|
78
|
+
vnet['name'] = params[:azure_vnet_name]
|
79
|
+
vnet['AffinityGroup'] = params[:azure_ag_name]
|
80
|
+
addr_space = Nokogiri::XML::Node.new('AddressSpace', response)
|
81
|
+
addr_prefix = Nokogiri::XML::Node.new('AddressPrefix', response)
|
82
|
+
addr_prefix.content = params[:azure_address_space]
|
83
|
+
addr_space.children = addr_prefix
|
84
|
+
vnet.children = addr_space
|
85
|
+
vnets.last.add_next_sibling(vnet) if add
|
86
|
+
@connection.query_azure('networking/media', 'put', response.to_xml)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
#
|
2
|
+
# Author:: Jeff Mendoza (jeffmendoza@live.com)
|
3
|
+
# Copyright:: Copyright (c) 2013 Opscode, Inc.
|
4
|
+
# License:: Apache License, Version 2.0
|
5
|
+
#
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
7
|
+
# you may not use this file except in compliance with the License.
|
8
|
+
# You may obtain a copy of the License at
|
9
|
+
#
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
11
|
+
#
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
14
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
15
|
+
# See the License for the specific language governing permissions and
|
16
|
+
# limitations under the License.
|
17
|
+
#
|
18
|
+
|
19
|
+
require File.expand_path('../azure_base', __FILE__)
|
20
|
+
|
21
|
+
class Chef
|
22
|
+
class Knife
|
23
|
+
class AzureAgCreate < Knife
|
24
|
+
include Knife::AzureBase
|
25
|
+
|
26
|
+
banner 'knife azure ag create (options)'
|
27
|
+
|
28
|
+
option :azure_affinity_group,
|
29
|
+
:short => '-a GROUP',
|
30
|
+
:long => '--azure-affinity-group GROUP',
|
31
|
+
:description => 'Specifies new affinity group name.'
|
32
|
+
|
33
|
+
option :azure_ag_desc,
|
34
|
+
:long => '--azure-ag-desc DESC',
|
35
|
+
:description => 'Optional. Description for new affinity group.'
|
36
|
+
|
37
|
+
option :azure_service_location,
|
38
|
+
:short => '-m LOCATION',
|
39
|
+
:long => '--azure-service-location LOCATION',
|
40
|
+
:description => 'Specifies the geographic location - the name of '\
|
41
|
+
'the data center location that is valid for your '\
|
42
|
+
'subscription. Eg: West US, East US, '\
|
43
|
+
'East Asia, Southeast Asia, North Europe, West Europe'
|
44
|
+
|
45
|
+
def run
|
46
|
+
$stdout.sync = true
|
47
|
+
|
48
|
+
Chef::Log.info('validating...')
|
49
|
+
validate!([:azure_subscription_id,
|
50
|
+
:azure_mgmt_cert,
|
51
|
+
:azure_api_host_name,
|
52
|
+
:azure_affinity_group,
|
53
|
+
:azure_service_location])
|
54
|
+
|
55
|
+
params = {
|
56
|
+
azure_ag_name: locate_config_value(:azure_affinity_group),
|
57
|
+
azure_ag_desc: locate_config_value(:azure_ag_desc),
|
58
|
+
azure_location: locate_config_value(:azure_service_location),
|
59
|
+
}
|
60
|
+
|
61
|
+
rsp = connection.ags.create(params)
|
62
|
+
print "\n"
|
63
|
+
if rsp.at_css('Status').nil?
|
64
|
+
if rsp.at_css('Code').nil? || rsp.at_css('Message').nil?
|
65
|
+
puts 'Unknown Error. try -VV'
|
66
|
+
else
|
67
|
+
puts "#{rsp.at_css('Code').content}: "\
|
68
|
+
"#{rsp.at_css('Message').content}"
|
69
|
+
end
|
70
|
+
else
|
71
|
+
puts "Creation status: #{rsp.at_css('Status').content}"
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
#
|
2
|
+
# Author:: Jeff Mendoza (jeffmendoza@live.com)
|
3
|
+
# Copyright:: Copyright (c) 2013 Opscode, Inc.
|
4
|
+
# License:: Apache License, Version 2.0
|
5
|
+
#
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
7
|
+
# you may not use this file except in compliance with the License.
|
8
|
+
# You may obtain a copy of the License at
|
9
|
+
#
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
11
|
+
#
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
14
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
15
|
+
# See the License for the specific language governing permissions and
|
16
|
+
# limitations under the License.
|
17
|
+
#
|
18
|
+
|
19
|
+
require File.expand_path('../azure_base', __FILE__)
|
20
|
+
|
21
|
+
class Chef
|
22
|
+
class Knife
|
23
|
+
class AzureAgList < Knife
|
24
|
+
include Knife::AzureBase
|
25
|
+
|
26
|
+
deps { require 'highline' }
|
27
|
+
|
28
|
+
banner 'knife azure ag list (options)'
|
29
|
+
|
30
|
+
def hl
|
31
|
+
@highline ||= HighLine.new
|
32
|
+
end
|
33
|
+
|
34
|
+
def run
|
35
|
+
$stdout.sync = true
|
36
|
+
|
37
|
+
validate!
|
38
|
+
|
39
|
+
cols = %w{Name Location Description}
|
40
|
+
|
41
|
+
the_list = cols.map { |col| ui.color(col, :bold) }
|
42
|
+
connection.ags.all.each do |ag|
|
43
|
+
cols.each { |attr| the_list << ag.send(attr.downcase).to_s }
|
44
|
+
end
|
45
|
+
|
46
|
+
puts "\n"
|
47
|
+
puts hl.list(the_list, :uneven_columns_across, cols.size)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -103,7 +103,16 @@ class Chef
|
|
103
103
|
msg_pair('DNS Name', server.hostedservicename + ".cloudapp.net")
|
104
104
|
msg_pair('VM Name', server.name)
|
105
105
|
msg_pair('Size', server.size)
|
106
|
+
msg_pair('Azure Source Image', locate_config_value(:azure_source_image))
|
107
|
+
msg_pair('Azure Service Location', locate_config_value(:azure_service_location))
|
106
108
|
msg_pair('Public Ip Address', server.publicipaddress)
|
109
|
+
msg_pair('Private Ip Address', server.ipaddress)
|
110
|
+
msg_pair('SSH Port', server.sshport) unless server.sshport.nil?
|
111
|
+
msg_pair('WinRM Port', server.winrmport) unless server.winrmport.nil?
|
112
|
+
msg_pair('TCP Ports', server.tcpports) unless server.tcpports.nil? or server.tcpports.empty?
|
113
|
+
msg_pair('UDP Ports', server.udpports) unless server.udpports.nil? or server.udpports.empty?
|
114
|
+
msg_pair('Environment', locate_config_value(:environment) || '_default')
|
115
|
+
msg_pair('Runlist', locate_config_value(:run_list)) unless locate_config_value(:run_list).empty?
|
107
116
|
puts "\n"
|
108
117
|
end
|
109
118
|
|
@@ -215,41 +215,68 @@ class Chef
|
|
215
215
|
(0...len).map{65.+(rand(25)).chr}.join
|
216
216
|
end
|
217
217
|
|
218
|
-
def wait_until_virtual_machine_ready(
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
polling_attempts = 1
|
227
|
-
until vm_ready || polling_attempts >= max_polling_attempts
|
228
|
-
print '.'
|
229
|
-
sleep retry_interval_in_seconds
|
230
|
-
vm_ready = check_if_virtual_machine_ready()
|
231
|
-
polling_attempts += 1
|
218
|
+
def wait_until_virtual_machine_ready(retry_interval_in_seconds = 30)
|
219
|
+
vm_status = nil
|
220
|
+
puts
|
221
|
+
|
222
|
+
begin
|
223
|
+
vm_status = wait_for_virtual_machine_state(:vm_status_provisioning, 5, retry_interval_in_seconds)
|
224
|
+
if vm_status != :vm_status_ready
|
225
|
+
wait_for_virtual_machine_state(:vm_status_ready, 15, retry_interval_in_seconds)
|
232
226
|
end
|
233
|
-
|
234
|
-
|
235
|
-
|
227
|
+
rescue Exception => e
|
228
|
+
Chef::Log.error("#{e.to_s}")
|
229
|
+
raise 'Verify connectivity to Azure and subscription resource limit compliance (e.g. maximum CPU core limits) and try again.'
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
def wait_for_virtual_machine_state(vm_status_goal, total_wait_time_in_minutes, retry_interval_in_seconds)
|
234
|
+
vm_status_ordering = {:vm_status_not_detected => 0, :vm_status_provisioning => 1, :vm_status_ready => 2}
|
235
|
+
vm_status_description = {:vm_status_not_detected => 'any', :vm_status_provisioning => 'provisioning', :vm_status_ready => 'ready'}
|
236
|
+
|
237
|
+
print ui.color("Waiting for virtual machine to reach status '#{vm_status_description[vm_status_goal]}'", :magenta)
|
238
|
+
|
239
|
+
total_wait_time_in_seconds = total_wait_time_in_minutes * 60
|
240
|
+
max_polling_attempts = total_wait_time_in_seconds / retry_interval_in_seconds
|
241
|
+
polling_attempts = 0
|
242
|
+
|
243
|
+
wait_start_time = Time.now
|
244
|
+
|
245
|
+
begin
|
246
|
+
vm_status = get_virtual_machine_status()
|
247
|
+
vm_ready = vm_status_ordering[vm_status] >= vm_status_ordering[vm_status_goal]
|
248
|
+
print '.'
|
249
|
+
sleep retry_interval_in_seconds if !vm_ready
|
250
|
+
polling_attempts += 1
|
251
|
+
end until vm_ready || polling_attempts >= max_polling_attempts
|
252
|
+
|
253
|
+
if ! vm_ready
|
254
|
+
raise Chef::Exceptions::CommandTimeout, "Virtual machine state '#{vm_status_description[vm_status_goal]}' not reached after #{total_wait_time_in_minutes} minutes."
|
255
|
+
end
|
256
|
+
|
257
|
+
elapsed_time_in_minutes = ((Time.now - wait_start_time) / 60).round(2)
|
258
|
+
print ui.color("vm state '#{vm_status_description[vm_status_goal]}' reached after #{elapsed_time_in_minutes} minutes.\n", :cyan)
|
259
|
+
vm_status
|
260
|
+
end
|
261
|
+
|
262
|
+
def get_virtual_machine_status()
|
263
|
+
role = get_role_server()
|
264
|
+
unless role.nil?
|
265
|
+
Chef::Log.debug("Role status is #{role.status.to_s}")
|
266
|
+
if "ReadyRole".eql? role.status.to_s
|
267
|
+
return :vm_status_ready
|
268
|
+
elsif "Provisioning".eql? role.status.to_s
|
269
|
+
return :vm_status_provisioning
|
236
270
|
else
|
237
|
-
|
271
|
+
return :vm_status_not_detected
|
238
272
|
end
|
273
|
+
end
|
274
|
+
return :vm_status_not_detected
|
239
275
|
end
|
240
276
|
|
241
|
-
def
|
277
|
+
def get_role_server()
|
242
278
|
deploy = connection.deploys.queryDeploy(locate_config_value(:azure_dns_name))
|
243
|
-
|
244
|
-
if role.nil?
|
245
|
-
raise "Could not find role - status unknown."
|
246
|
-
end
|
247
|
-
Chef::Log.debug("Role status is #{role.status.to_s}")
|
248
|
-
if "ReadyRole".eql? role.status.to_s
|
249
|
-
return true
|
250
|
-
else
|
251
|
-
return false
|
252
|
-
end
|
279
|
+
deploy.find_role(locate_config_value(:azure_vm_name))
|
253
280
|
end
|
254
281
|
|
255
282
|
def tcp_test_winrm(ip_addr, port)
|
@@ -335,11 +362,14 @@ class Chef
|
|
335
362
|
end
|
336
363
|
|
337
364
|
begin
|
338
|
-
|
339
|
-
fqdn = server.publicipaddress
|
365
|
+
connection.deploys.create(create_server_def)
|
340
366
|
wait_until_virtual_machine_ready()
|
367
|
+
server = get_role_server()
|
368
|
+
fqdn = server.publicipaddress
|
341
369
|
rescue Exception => e
|
342
370
|
Chef::Log.error("Failed to create the server -- exception being rescued: #{e.to_s}")
|
371
|
+
backtrace_message = "#{e.class}: #{e}\n#{e.backtrace.join("\n")}"
|
372
|
+
Chef::Log.debug("#{backtrace_message}")
|
343
373
|
cleanup_and_exit(remove_hosted_service_on_failure, remove_storage_service_on_failure)
|
344
374
|
end
|
345
375
|
|
@@ -71,6 +71,12 @@ class Chef
|
|
71
71
|
option :azure_dns_name,
|
72
72
|
:long => "--azure-dns-name NAME",
|
73
73
|
:description => "specifies the DNS name (also known as hosted service name)"
|
74
|
+
|
75
|
+
option :wait,
|
76
|
+
:long => "--wait",
|
77
|
+
:boolean => true,
|
78
|
+
:default => false,
|
79
|
+
:description => "Wait for server deletion. Default is false"
|
74
80
|
|
75
81
|
# Extracted from Chef::Knife.delete_object, because it has a
|
76
82
|
# confirmation step built in... By specifying the '--purge'
|
@@ -127,7 +133,8 @@ class Chef
|
|
127
133
|
:preserve_azure_vhd => locate_config_value(:preserve_azure_vhd),
|
128
134
|
:preserve_azure_dns_name => locate_config_value(:preserve_azure_dns_name),
|
129
135
|
:azure_dns_name => server.hostedservicename,
|
130
|
-
:delete_azure_storage_account => locate_config_value(:delete_azure_storage_account)
|
136
|
+
:delete_azure_storage_account => locate_config_value(:delete_azure_storage_account),
|
137
|
+
:wait => locate_config_value(:wait) })
|
131
138
|
|
132
139
|
puts "\n"
|
133
140
|
ui.warn("Deleted server #{server.name}")
|
@@ -22,11 +22,11 @@ require File.expand_path('../azure_base', __FILE__)
|
|
22
22
|
|
23
23
|
class Chef
|
24
24
|
class Knife
|
25
|
-
class
|
25
|
+
class AzureServerShow < Knife
|
26
26
|
|
27
27
|
include Knife::AzureBase
|
28
28
|
|
29
|
-
banner "knife azure server
|
29
|
+
banner "knife azure server show SERVER [SERVER]"
|
30
30
|
|
31
31
|
def run
|
32
32
|
$stdout.sync = true
|
@@ -38,27 +38,35 @@ class Chef
|
|
38
38
|
puts ''
|
39
39
|
if (role)
|
40
40
|
details = Array.new
|
41
|
-
details << ui.color('Role name', :bold, :
|
41
|
+
details << ui.color('Role name', :bold, :cyan)
|
42
42
|
details << role.name
|
43
|
-
details << ui.color('Status', :bold, :
|
43
|
+
details << ui.color('Status', :bold, :cyan)
|
44
44
|
details << role.status
|
45
|
-
details << ui.color('Size', :bold, :
|
45
|
+
details << ui.color('Size', :bold, :cyan)
|
46
46
|
details << role.size
|
47
|
-
details << ui.color('Hosted service name', :bold, :
|
47
|
+
details << ui.color('Hosted service name', :bold, :cyan)
|
48
48
|
details << role.hostedservicename
|
49
|
-
details << ui.color('Deployment name', :bold, :
|
49
|
+
details << ui.color('Deployment name', :bold, :cyan)
|
50
50
|
details << role.deployname
|
51
|
-
details << ui.color('Host name', :bold, :
|
51
|
+
details << ui.color('Host name', :bold, :cyan)
|
52
52
|
details << role.hostname
|
53
|
-
|
54
|
-
|
53
|
+
unless role.sshport.nil?
|
54
|
+
details << ui.color('SSH port', :bold, :cyan)
|
55
|
+
details << role.sshport
|
56
|
+
end
|
57
|
+
unless role.winrmport.nil?
|
58
|
+
details << ui.color('WinRM port', :bold, :cyan)
|
59
|
+
details << role.winrmport
|
60
|
+
end
|
61
|
+
details << ui.color('Public IP', :bold, :cyan)
|
62
|
+
details << role.publicipaddress
|
55
63
|
puts ui.list(details, :columns_across, 2)
|
56
64
|
if role.tcpports.length > 0 || role.udpports.length > 0
|
57
65
|
details.clear
|
58
|
-
details << ui.color('Ports open', :bold, :
|
59
|
-
details << ui.color('Local port', :bold, :
|
60
|
-
details << ui.color('IP', :bold, :
|
61
|
-
details << ui.color('Public port', :bold, :
|
66
|
+
details << ui.color('Ports open', :bold, :cyan)
|
67
|
+
details << ui.color('Local port', :bold, :cyan)
|
68
|
+
details << ui.color('IP', :bold, :cyan)
|
69
|
+
details << ui.color('Public port', :bold, :cyan)
|
62
70
|
if role.tcpports.length > 0
|
63
71
|
role.tcpports.each do |port|
|
64
72
|
details << 'tcp'
|