kitchen-openstack 7.0.1 → 8.0.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.
@@ -28,47 +28,100 @@ module Kitchen
28
28
  class Openstack < Kitchen::Driver::Base
29
29
  # Floating IP allocation and IP address resolution
30
30
  module Networking
31
+ # Serializes floating IP selection across the driver instances that
32
+ # Test Kitchen runs in parallel. Without it, two concurrent converges
33
+ # can pick the same free address out of the pool and one of them fails
34
+ # to associate it.
35
+ #
36
+ # @return [Mutex]
31
37
  IP_POOL_LOCK = Mutex.new
32
38
 
33
39
  private
34
40
 
41
+ # Attaches a floating IP from the named pool to the server, allocating
42
+ # a fresh one first when `:allocate_floating_ip` is set.
43
+ #
44
+ # Held under {IP_POOL_LOCK} for the whole select-then-attach sequence,
45
+ # so a concurrent converge cannot claim the same address in between.
46
+ #
47
+ # @param server [Fog::OpenStack::Compute::Server] the server to attach to
48
+ # @param pool [String] name of the floating IP pool / external network
49
+ # @return [void]
50
+ # @raise [Kitchen::ActionFailed] if the pool does not exist, or holds no
51
+ # free addresses
35
52
  def attach_ip_from_pool(server, pool)
36
53
  IP_POOL_LOCK.synchronize do
37
54
  info "Attaching floating IP from <#{pool}> pool"
38
- if config[:allocate_floating_ip]
39
- network_id = network
40
- .list_networks(
41
- name: pool
42
- ).body["networks"][0]["id"]
43
- resp = network.create_floating_ip(network_id)
44
- ip = resp.body["floatingip"]["floating_ip_address"]
45
- info "Created floating IP <#{ip}> from <#{pool}> pool"
46
- config[:floating_ip] = ip
47
- else
48
- free_addrs = compute.addresses.map do |i|
49
- i.ip if i.fixed_ip.nil? && i.instance_id.nil? && i.pool == pool
50
- end.compact
51
- if free_addrs.empty?
52
- raise ActionFailed, "No available IPs in pool <#{pool}>"
53
- end
54
-
55
- config[:floating_ip] = free_addrs[0]
56
- end
55
+ config[:floating_ip] = if config[:allocate_floating_ip]
56
+ allocate_ip_from_pool(pool)
57
+ else
58
+ free_ip_from_pool(pool)
59
+ end
57
60
  attach_ip(server, config[:floating_ip])
58
61
  end
59
62
  end
60
63
 
64
+ # Asks Neutron for a brand new floating IP on the named external
65
+ # network.
66
+ #
67
+ # @param pool [String] name of the external network
68
+ # @return [String] the newly allocated floating IP
69
+ # @raise [Kitchen::ActionFailed] if no network matches `pool`
70
+ def allocate_ip_from_pool(pool)
71
+ net = network
72
+ networks = net.list_networks(name: pool).body["networks"]
73
+ if networks.nil? || networks.empty?
74
+ raise ActionFailed, "Floating IP pool <#{pool}> not found"
75
+ end
76
+
77
+ resp = net.create_floating_ip(networks[0]["id"])
78
+ ip = resp.body["floatingip"]["floating_ip_address"]
79
+ info "Created floating IP <#{ip}> from <#{pool}> pool"
80
+ ip
81
+ end
82
+
83
+ # Picks an already-allocated but unattached floating IP out of the pool.
84
+ #
85
+ # @param pool [String] name of the floating IP pool
86
+ # @return [String] a free floating IP
87
+ # @raise [Kitchen::ActionFailed] if every address in the pool is in use
88
+ def free_ip_from_pool(pool)
89
+ # `find`, not `map`+`compact`: this runs while holding IP_POOL_LOCK,
90
+ # which serializes parallel converges, so it should stop at the first
91
+ # usable address rather than building a throwaway list of all of them.
92
+ free = compute.addresses.find do |i|
93
+ i.fixed_ip.nil? && i.instance_id.nil? && i.pool == pool
94
+ end
95
+ raise ActionFailed, "No available IPs in pool <#{pool}>" if free.nil?
96
+
97
+ free.ip
98
+ end
99
+
100
+ # Associates a floating IP with a server.
101
+ #
102
+ # @param server [Fog::OpenStack::Compute::Server] the server
103
+ # @param ip [String] the floating IP to attach
104
+ # @return [void]
61
105
  def attach_ip(server, ip)
62
106
  info "Attaching floating IP <#{ip}>"
63
107
  server.associate_address ip
64
108
  end
65
109
 
110
+ # Reads the server's public and private addresses.
111
+ #
112
+ # Deployments without the floating IP extension answer the dedicated
113
+ # accessors with 404/403, so fall back to picking the lists out of the
114
+ # generic addresses hash.
115
+ #
116
+ # @see https://github.com/fog/fog/issues/2160
117
+ # @param server [Fog::OpenStack::Compute::Server] the server
118
+ # @return [Array(Array<String>, Array<String>)] public and private
119
+ # addresses, either of which may be nil
66
120
  def get_public_private_ips(server)
67
121
  begin
68
122
  pub = server.public_ip_addresses
69
123
  priv = server.private_ip_addresses
70
124
  rescue Fog::OpenStack::Compute::NotFound, Excon::Errors::Forbidden
71
- # See Fog issue: https://github.com/fog/fog/issues/2160
72
125
  addrs = server.addresses
73
126
  addrs["public"] && pub = addrs["public"].map { |i| i["addr"] }
74
127
  addrs["private"] && priv = addrs["private"].map { |i| i["addr"] }
@@ -76,6 +129,19 @@ module Kitchen
76
129
  [pub, priv]
77
130
  end
78
131
 
132
+ # Determines the address Test Kitchen should connect to.
133
+ #
134
+ # Resolution order:
135
+ #
136
+ # 1. an explicitly configured `:floating_ip`
137
+ # 2. the first address on `:openstack_network_name`, if configured
138
+ # 3. `:public_ip_order` into the public addresses
139
+ # 4. `:private_ip_order` into the private addresses
140
+ #
141
+ # @param server [Fog::OpenStack::Compute::Server] the server
142
+ # @return [String] the address to connect to
143
+ # @raise [Kitchen::ActionFailed] if network information never arrives,
144
+ # or no address of the requested family can be found
79
145
  def get_ip(server)
80
146
  if config[:floating_ip]
81
147
  debug "Using floating ip: #{config[:floating_ip]}"
@@ -91,11 +157,7 @@ module Kitchen
91
157
  raise ActionFailed, "Could not get network information (timed out)"
92
158
  end
93
159
 
94
- # should also work for private networks
95
- if config[:openstack_network_name]
96
- debug "Using configured net: #{config[:openstack_network_name]}"
97
- return filter_ips(server.addresses[config[:openstack_network_name]]).first["addr"]
98
- end
160
+ return ip_from_named_network(server) if config[:openstack_network_name]
99
161
 
100
162
  pub, priv = get_public_private_ips(server)
101
163
  priv = server.ip_addresses if Array(pub).empty? && Array(priv).empty?
@@ -105,6 +167,33 @@ module Kitchen
105
167
  raise(ActionFailed, "Could not find an IP")
106
168
  end
107
169
 
170
+ # Picks the first usable address off the network named by
171
+ # `:openstack_network_name`.
172
+ #
173
+ # @param server [Fog::OpenStack::Compute::Server] the server
174
+ # @return [String] the address
175
+ # @raise [Kitchen::ActionFailed] if the server is not on that network,
176
+ # or has no address there of the configured IP family
177
+ def ip_from_named_network(server)
178
+ name = config[:openstack_network_name]
179
+ debug "Using configured net: #{name}"
180
+
181
+ addresses = server.addresses[name]
182
+ raise ActionFailed, "Server is not attached to network <#{name}>" if addresses.nil?
183
+
184
+ matching = filter_ips(addresses)
185
+ if matching.empty?
186
+ raise ActionFailed,
187
+ "No #{config[:use_ipv6] ? "IPv6" : "IPv4"} address found on network <#{name}>"
188
+ end
189
+
190
+ matching.first["addr"]
191
+ end
192
+
193
+ # Keeps only the addresses matching the configured IP family.
194
+ #
195
+ # @param addresses [Array<Hash>] address hashes, each with an `"addr"` key
196
+ # @return [Array<Hash>] the matching subset
108
197
  def filter_ips(addresses)
109
198
  if config[:use_ipv6]
110
199
  addresses.select { |i| IPAddr.new(i["addr"]).ipv6? }
@@ -113,15 +202,21 @@ module Kitchen
113
202
  end
114
203
  end
115
204
 
205
+ # Normalizes and filters public/private address lists to the configured
206
+ # IP family.
207
+ #
208
+ # @param pub [Array<String>, String, nil] public addresses
209
+ # @param priv [Array<String>, String, nil] private addresses
210
+ # @return [Array(Array<String>, Array<String>)] filtered public and
211
+ # private address lists
116
212
  def parse_ips(pub, priv)
117
- pub = Array(pub)
118
- priv = Array(priv)
119
- if config[:use_ipv6]
120
- [pub, priv].each { |n| n.select! { |i| IPAddr.new(i).ipv6? } }
121
- else
122
- [pub, priv].each { |n| n.select! { |i| IPAddr.new(i).ipv4? } }
213
+ # `select`, not `select!`: Array(x) returns x itself when x is already
214
+ # an Array, so filtering in place would edit the caller's list -- and
215
+ # the caller's list here is the Fog server model's own address data.
216
+ wanted = config[:use_ipv6] ? :ipv6? : :ipv4?
217
+ [Array(pub), Array(priv)].map do |addrs|
218
+ addrs.select { |i| IPAddr.new(i).public_send(wanted) }
123
219
  end
124
- [pub, priv]
125
220
  end
126
221
  end
127
222
  end
@@ -26,8 +26,29 @@ module Kitchen
26
26
  class Openstack < Kitchen::Driver::Base
27
27
  # Server creation and resource finders (image, flavor, network)
28
28
  module ServerHelper
29
+ # Config keys copied onto the server definition when set, each routed
30
+ # through {#optional_config}.
31
+ #
32
+ # @return [Array<Symbol>]
33
+ OPTIONAL_SERVER_KEYS = %i{
34
+ security_groups
35
+ key_name
36
+ user_data
37
+ config_drive
38
+ metadata
39
+ }.freeze
40
+
29
41
  private
30
42
 
43
+ # Builds the Nova server definition and submits it.
44
+ #
45
+ # Fog's `bootstrap`/`setup` helpers are deliberately not used: they
46
+ # require a public IP address, which is not guaranteed to exist on
47
+ # every OpenStack deployment.
48
+ #
49
+ # @return [Fog::OpenStack::Compute::Server] the newly created server
50
+ # @raise [Kitchen::ActionFailed] on mutually exclusive or unresolvable
51
+ # configuration
31
52
  def create_server
32
53
  server_def = init_configuration
33
54
  raise(ActionFailed, "Cannot specify both network_ref and network_id") if config[:network_id] && config[:network_ref]
@@ -44,17 +65,7 @@ module Kitchen
44
65
  end
45
66
  end
46
67
 
47
- if config[:block_device_mapping]
48
- server_def[:block_device_mapping] = get_bdm(config)
49
- end
50
-
51
- %i{
52
- security_groups
53
- key_name
54
- user_data
55
- config_drive
56
- metadata
57
- }.each do |c|
68
+ OPTIONAL_SERVER_KEYS.each do |c|
58
69
  server_def[c] = optional_config(c) if config[c]
59
70
  end
60
71
 
@@ -64,12 +75,25 @@ module Kitchen
64
75
  server_def[:user_data] = YAML.dump(Kitchen::Util.stringified_hash(config[:cloud_config])).gsub(/^---\n/, "#cloud-config\n")
65
76
  end
66
77
 
67
- # Can't use the Fog bootstrap and/or setup methods here; they require a
68
- # public IP address that can't be guaranteed to exist across all
69
- # OpenStack deployments (e.g. TryStack ARM only has private IPs).
78
+ # Last, because this is the only step that creates a resource. Every
79
+ # check above is local config validation, and running them first means
80
+ # a bad security_groups or user_data value cannot strand a Cinder
81
+ # volume whose id exists only in the server_def about to be discarded.
82
+ if config[:block_device_mapping]
83
+ server_def[:block_device_mapping] = get_bdm(config)
84
+ end
85
+
70
86
  compute.servers.create(server_def)
71
87
  end
72
88
 
89
+ # Builds the mandatory part of the server definition.
90
+ #
91
+ # `*_id` and `*_ref` are mutually exclusive: an id is used verbatim, a
92
+ # ref is resolved by name, id or regex through {#find_matching}.
93
+ #
94
+ # @return [Hash] name, image, flavor and availability zone
95
+ # @raise [Kitchen::ActionFailed] if both an id and a ref are given for
96
+ # the image or the flavor, or if either cannot be resolved
73
97
  def init_configuration
74
98
  raise(ActionFailed, "Cannot specify both image_ref and image_id") if config[:image_id] && config[:image_ref]
75
99
  raise(ActionFailed, "Cannot specify both flavor_ref and flavor_id") if config[:flavor_id] && config[:flavor_ref]
@@ -82,17 +106,39 @@ module Kitchen
82
106
  }
83
107
  end
84
108
 
109
+ # Resolves one optional server setting to the value Nova expects.
110
+ #
111
+ # @param c [Symbol] the config key
112
+ # @return [Object] the resolved value
113
+ # @raise [Kitchen::ActionFailed] if `:security_groups` is not a list, or
114
+ # if the `:user_data` file does not exist
85
115
  def optional_config(c)
86
116
  case c
87
117
  when :security_groups
88
- config[c] if config[c].is_a?(Array)
118
+ unless config[c].is_a?(Array)
119
+ raise ActionFailed, "The security_groups config must be an array, got #{config[c].class}"
120
+ end
121
+
122
+ config[c]
89
123
  when :user_data
90
- File.read(config[c]) if File.exist?(config[c])
124
+ # Booting without the user_data the user asked for produces a
125
+ # server that looks fine and behaves wrongly, so a missing file is
126
+ # fatal rather than silently ignored.
127
+ unless File.exist?(config[c])
128
+ raise ActionFailed, "The user_data file <#{config[c]}> does not exist"
129
+ end
130
+
131
+ File.read(config[c])
91
132
  else
92
133
  config[c]
93
134
  end
94
135
  end
95
136
 
137
+ # Finds a Glance image by id, name or regex.
138
+ #
139
+ # @param image_ref [String] id, name, or `/regex/`
140
+ # @return [Object] the matching image
141
+ # @raise [Kitchen::ActionFailed] if nothing matches
96
142
  def find_image(image_ref)
97
143
  image = find_matching(compute.images, image_ref)
98
144
  raise(ActionFailed, "Image not found") unless image
@@ -101,6 +147,11 @@ module Kitchen
101
147
  image
102
148
  end
103
149
 
150
+ # Finds a Nova flavor by id, name or regex.
151
+ #
152
+ # @param flavor_ref [String] id, name, or `/regex/`
153
+ # @return [Object] the matching flavor
154
+ # @raise [Kitchen::ActionFailed] if nothing matches
104
155
  def find_flavor(flavor_ref)
105
156
  flavor = find_matching(compute.flavors, flavor_ref)
106
157
  raise(ActionFailed, "Flavor not found") unless flavor
@@ -109,6 +160,11 @@ module Kitchen
109
160
  flavor
110
161
  end
111
162
 
163
+ # Finds a Neutron network by id, name or regex.
164
+ #
165
+ # @param network_ref [String] id, name, or `/regex/`
166
+ # @return [Object] the matching network
167
+ # @raise [Kitchen::ActionFailed] if nothing matches
112
168
  def find_network(network_ref)
113
169
  net = find_matching(network.networks.all, network_ref)
114
170
  raise(ActionFailed, "Network not found") unless net
@@ -117,15 +173,31 @@ module Kitchen
117
173
  net
118
174
  end
119
175
 
176
+ # Picks a resource out of a Fog collection.
177
+ #
178
+ # A ref wrapped in forward slashes is treated as a regular expression
179
+ # matched against the resource name; anything else is compared against
180
+ # the id first and then the name, so an exact id always wins.
181
+ #
182
+ # @example an exact name
183
+ # find_matching(compute.images, "ubuntu-24.04")
184
+ # @example a regular expression
185
+ # find_matching(compute.images, "/^ubuntu-24\\.04/")
186
+ #
187
+ # @param collection [Enumerable] the Fog collection to search
188
+ # @param name [String] id, name, or `/regex/`
189
+ # @return [Object, nil] the first match, or nil
120
190
  def find_matching(collection, name)
121
191
  name = name.to_s
122
192
  if name.start_with?("/") && name.end_with?("/")
123
193
  regex = Regexp.new(name[1...-1])
124
- # check for regex name match
125
- collection.each { |single| return single if regex&.match?(single.name) }
194
+ # check for regex name match, skipping unnamed resources; Neutron
195
+ # networks in particular are allowed to have no name
196
+ collection.each { |single| return single if single.name && regex.match?(single.name) }
126
197
  else
127
- # check for exact id match
128
- collection.each { |single| return single if single.id == name }
198
+ # check for exact id match; ids come back as integers on some
199
+ # deployments, so compare as strings
200
+ collection.each { |single| return single if single.id.to_s == name }
129
201
  # check for exact name match
130
202
  collection.each { |single| return single if single.name == name }
131
203
  end
@@ -26,28 +26,70 @@ module Kitchen
26
26
  # A class to allow the Kitchen Openstack driver
27
27
  # to use Openstack volumes
28
28
  #
29
+ # Instances of this class translate a `block_device_mapping` config hash
30
+ # into the shape Nova expects, creating a Cinder volume first when the
31
+ # mapping asks for one.
32
+ #
29
33
  # @author Liam Haworth <liam.haworth@bluereef.com.au>
30
34
  class Volume
35
+ # Seconds to wait for a newly created volume to become available when
36
+ # the block device mapping does not specify `creation_timeout`.
37
+ #
38
+ # @return [Integer]
31
39
  DEFAULT_CREATION_TIMEOUT = 60
32
40
 
41
+ # Block device mapping keys forwarded verbatim to Cinder's create call.
42
+ #
43
+ # @return [Array<Symbol>]
44
+ VANILLA_VOLUME_OPTIONS = %i{
45
+ snapshot_id
46
+ imageRef
47
+ volume_type
48
+ source_volid
49
+ availability_zone
50
+ }.freeze
51
+
52
+ # @param logger [Kitchen::Logger] logger to report volume progress to
33
53
  def initialize(logger)
34
54
  @logger = logger
35
55
  end
36
56
 
57
+ # Builds a Cinder connection.
58
+ #
59
+ # @param openstack_server [Hash] Fog connection settings
60
+ # @return [Fog::OpenStack::Volume] a Cinder service object
37
61
  def volume(openstack_server)
38
62
  Fog::OpenStack::Volume.new(openstack_server)
39
63
  end
40
64
 
65
+ # Creates a Cinder volume and blocks until it is available.
66
+ #
67
+ # @param config [Hash] the driver config, read for `:server_name` and
68
+ # `:block_device_mapping`
69
+ # @param os [Hash] Fog connection settings
70
+ # @return [String] the id of the newly created volume
71
+ # @raise [Kitchen::ActionFailed] if a timeout is not a number, if the
72
+ # volume cannot be found after creation, or if it enters an `error`
73
+ # state
74
+ # @raise [Fog::Errors::TimeoutError] if the volume is not available
75
+ # before `:creation_timeout` elapses
41
76
  def create_volume(config, os)
42
- opt = {}
43
77
  bdm = config[:block_device_mapping]
44
- vanilla_options = %i{snapshot_id imageRef volume_type
45
- source_volid availability_zone}
46
- vanilla_options.select { |o| bdm[o] }.each do |key|
47
- opt[key] = bdm[key]
48
- end
78
+
79
+ # Read both timeouts before anything is created. They are pure config
80
+ # parsing, so a bad value should cost the user an error message, not
81
+ # an orphaned volume that `kitchen destroy` cannot see.
82
+ creation_timeout = timeout_value(bdm, :creation_timeout, DEFAULT_CREATION_TIMEOUT)
83
+ attach_timeout = timeout_value(bdm, :attach_timeout, 0)
84
+
85
+ opt = VANILLA_VOLUME_OPTIONS.select { |o| bdm[o] }.to_h { |key| [key, bdm[key]] }
86
+
87
+ # Build the Cinder connection once and reuse it for the readiness
88
+ # lookup, rather than authenticating to Keystone a second time.
89
+ volume_service = volume(os)
90
+
49
91
  @logger.info "Creating Volume..."
50
- resp = volume(os)
92
+ resp = volume_service
51
93
  .create_volume(
52
94
  "#{config[:server_name]}-volume",
53
95
  "#{config[:server_name]} volume",
@@ -56,41 +98,95 @@ module Kitchen
56
98
  )
57
99
  vol_id = resp[:body]["volume"]["id"]
58
100
 
59
- # Get Volume Model to make waiting for ready easy
60
- vol_model = volume(os).volumes.first { |x| x.id == vol_id }
101
+ wait_for_volume(volume_service, vol_id, creation_timeout, attach_timeout)
61
102
 
62
- # Use default creation timeout or user supplied
63
- creation_timeout = DEFAULT_CREATION_TIMEOUT
64
- if bdm.key?(:creation_timeout)
65
- creation_timeout = bdm[:creation_timeout]
66
- end
103
+ vol_id
104
+ end
105
+
106
+ # Resolves the block device mapping to hand to Nova, creating the
107
+ # backing volume first if `:make_volume` is set.
108
+ #
109
+ # The returned hash is a copy: the driver's own config is never
110
+ # mutated, so a retried `create` sees the same input it did the first
111
+ # time.
112
+ #
113
+ # @param config [Hash] the driver config
114
+ # @param os [Hash] Fog connection settings
115
+ # @return [Hash] a block device mapping suitable for Nova
116
+ def get_bdm(config, os)
117
+ bdm = config[:block_device_mapping].dup
118
+ bdm[:volume_id] = create_volume(config, os) if bdm[:make_volume]
119
+ bdm.delete(:make_volume)
120
+ bdm.delete(:snapshot_id)
121
+ bdm
122
+ end
123
+
124
+ private
125
+
126
+ # Blocks until the named volume reports ready, then honours any
127
+ # additional `:attach_timeout` grace period.
128
+ #
129
+ # @param volume_service [Fog::OpenStack::Volume] an established Cinder
130
+ # connection
131
+ # @param vol_id [String] id of the volume to wait on
132
+ # @param creation_timeout [Integer] seconds to wait for readiness
133
+ # @param attach_timeout [Integer] extra seconds to sleep once ready
134
+ # @return [void]
135
+ # @raise [Kitchen::ActionFailed] if the volume cannot be fetched back
136
+ # or enters an `error` state
137
+ def wait_for_volume(volume_service, vol_id, creation_timeout, attach_timeout)
138
+ # Fetch the volume by id rather than scanning the collection: a list
139
+ # call returns a single page (Cinder caps it at osapi_max_limit), so
140
+ # in a project with more volumes than that the one just created may
141
+ # not appear on it. `get` is a direct GET and returns nil on 404.
142
+ vol_model = volume_service.volumes.get(vol_id)
143
+ raise(ActionFailed, "Volume #{vol_id} disappeared after creation") if vol_model.nil?
67
144
 
68
145
  @logger.debug "Waiting for volume to be ready for #{creation_timeout} seconds"
69
146
  vol_model.wait_for(creation_timeout) do
70
147
  sleep(1)
71
- raise("Failed to make volume") if status.casecmp("error".downcase) == 0
148
+ raise(ActionFailed, "Failed to make volume #{vol_id}") if status.casecmp("error") == 0
72
149
 
73
150
  ready?
74
151
  end
75
152
 
76
- attach_timeout = bdm.key?(:attach_timeout) ? bdm[:attach_timeout] : 0
77
-
78
153
  if attach_timeout > 0
79
154
  @logger.debug "Sleeping for an additional #{attach_timeout} seconds before attaching volume to wait for Openstack to finish disk creation process.."
80
155
  sleep(attach_timeout)
81
156
  end
82
157
 
83
158
  @logger.debug "Volume Ready"
84
-
85
- vol_id
86
159
  end
87
160
 
88
- def get_bdm(config, os)
89
- bdm = config[:block_device_mapping]
90
- bdm[:volume_id] = create_volume(config, os) if bdm[:make_volume]
91
- bdm.delete_if { |k, _| k == :make_volume }
92
- bdm.delete_if { |k, _| k == :snapshot_id }
93
- bdm
161
+ # Reads a timeout out of the block device mapping as an Integer.
162
+ #
163
+ # YAML happily parses `attach_timeout: 5` as an Integer but
164
+ # `attach_timeout: "5"` as a String, and comparing a String to 0 raises.
165
+ # Coerce so both spellings work.
166
+ #
167
+ # Base 10 is explicit: a bare `Integer("010")` would read the leading
168
+ # zero as octal and quietly wait 8 seconds instead of 10, and
169
+ # `Integer("08")` would raise outright.
170
+ #
171
+ # @param bdm [Hash] the block device mapping
172
+ # @param key [Symbol] the timeout key to read
173
+ # @param default [Integer] value to use when the key is absent or empty
174
+ # @return [Integer] the timeout in seconds
175
+ # @raise [Kitchen::ActionFailed] if the value is present but not a
176
+ # number
177
+ def timeout_value(bdm, key, default)
178
+ value = bdm[key]
179
+ # `attach_timeout:` with nothing after it parses to nil.
180
+ return default if value.nil? || value.to_s.strip.empty?
181
+
182
+ begin
183
+ # The base argument is only legal for a String; passing one
184
+ # alongside an Integer raises "base specified for non string value".
185
+ value.is_a?(String) ? Integer(value, 10) : Integer(value)
186
+ rescue ArgumentError, TypeError
187
+ raise(ActionFailed,
188
+ "The block_device_mapping #{key} must be a number, got #{value.inspect}")
189
+ end
94
190
  end
95
191
  end
96
192
  end