kitchen-openstack 7.0.0 → 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.
@@ -48,6 +48,10 @@ module Kitchen
48
48
  "identity_api_version" => :openstack_identity_api_version,
49
49
  }.freeze
50
50
 
51
+ # Fog expects these config values to be strings. YAML may parse
52
+ # unquoted scalars as integers/booleans, so normalize on ingest.
53
+ STRING_CONFIG_KEYS = (CLOUDS_YAML_AUTH_MAP.values + CLOUDS_YAML_TOP_MAP.values).freeze
54
+
51
55
  # Mapping of OS_* environment variables to Fog OpenStack config keys
52
56
  ENV_VAR_MAP = {
53
57
  "OS_AUTH_URL" => :openstack_auth_url,
@@ -72,9 +76,12 @@ module Kitchen
72
76
  private
73
77
 
74
78
  # Merges external config sources into the driver config hash.
75
- # Precedence: kitchen.yml > OS_* env vars > clouds.yaml
76
- # Only sets keys that are currently nil so that kitchen.yml
77
- # values always take precedence.
79
+ #
80
+ # Precedence, highest first: kitchen.yml, `OS_*` environment
81
+ # variables, then clouds.yaml. Only keys that are currently nil are
82
+ # written, so anything set explicitly in kitchen.yml always wins.
83
+ #
84
+ # @return [void]
78
85
  def apply_clouds_config
79
86
  cc = load_clouds_config
80
87
  env = load_env_vars
@@ -87,75 +94,162 @@ module Kitchen
87
94
  config[key] = value if config[key].nil?
88
95
  end
89
96
 
90
- # Apply SSL settings: env vars or clouds.yaml disabling verification
91
- ssl_verify = env.key?(:ssl_verify_peer) ? env[:ssl_verify_peer] : cc[:ssl_verify_peer]
92
- return unless ssl_verify == false && !config[:disable_ssl_validation]
97
+ # `verify: false` in clouds.yaml is how operators opt out of TLS
98
+ # verification. openstacksdk documents no environment variable for it
99
+ # (though its env loader does sweep up any OS_* name into an implicit
100
+ # cloud), and ENV_VAR_MAP deliberately mirrors the documented set --
101
+ # so for this driver clouds.yaml is the only source.
102
+ return unless cc[:ssl_verify_peer] == false && !config[:disable_ssl_validation]
93
103
 
94
104
  config[:disable_ssl_validation] = true
95
105
  end
96
106
 
97
- # Reads OS_* environment variables and maps them to Fog config keys.
98
- # Returns a hash of fog config symbols for any set env vars.
107
+ # Reads `OS_*` environment variables and maps them to Fog config keys.
108
+ #
109
+ # Empty variables are ignored, so `OS_REGION_NAME=""` does not shadow
110
+ # a region set in clouds.yaml.
111
+ #
112
+ # @return [Hash{Symbol => Object}] Fog config keys for the variables
113
+ # that are set
99
114
  def load_env_vars
100
115
  result = {}
101
116
  ENV_VAR_MAP.each do |env_var, fog_key|
102
117
  value = ENV[env_var]
103
- result[fog_key] = value if value && !value.empty?
118
+ result[fog_key] = normalize_config_value(fog_key, value) if value && !value.empty?
104
119
  end
105
120
  result
106
121
  end
107
122
 
108
- # Resolves the cloud name from config or the OS_CLOUD environment variable
123
+ # Resolves which named cloud to read out of clouds.yaml.
124
+ #
125
+ # @return [String, nil] the cloud name, or nil if none is configured
109
126
  def cloud_name
110
127
  config[:openstack_cloud] || ENV["OS_CLOUD"]
111
128
  end
112
129
 
113
130
  # Loads and merges clouds.yaml with secure.yaml, then translates the
114
131
  # named cloud entry into Fog-compatible config keys.
115
- # Returns a hash of fog config symbols, or empty hash if no cloud configured.
132
+ #
133
+ # secure.yaml wins over clouds.yaml, matching openstacksdk.
134
+ #
135
+ # @return [Hash{Symbol => Object}] Fog config keys, or an empty hash
136
+ # when no cloud is configured or the named cloud is absent
116
137
  def load_clouds_config
117
138
  name = cloud_name
118
139
  return {} unless name
119
140
 
120
- clouds_data = load_yaml_file("clouds.yaml", "OS_CLIENT_CONFIG_FILE")
121
- secure_data = load_yaml_file("secure.yaml", "OS_CLIENT_SECURE_FILE")
141
+ clouds_data, clouds_path = load_yaml_file("clouds.yaml", "OS_CLIENT_CONFIG_FILE")
142
+ secure_data, secure_path = load_yaml_file("secure.yaml", "OS_CLIENT_SECURE_FILE")
122
143
 
123
- cloud = extract_cloud(clouds_data, name)
124
- secure = extract_cloud(secure_data, name)
144
+ cloud = extract_cloud(clouds_data, name, clouds_path)
145
+ secure = extract_cloud(secure_data, name, secure_path)
125
146
 
126
147
  cloud = deep_merge(cloud, secure)
127
148
  translate_cloud_config(cloud)
128
149
  end
129
150
 
130
- # Search standard OpenStack config file locations for the given filename
151
+ # Loads the first of the standard OpenStack config locations that
152
+ # exists.
153
+ #
154
+ # @param filename [String] `"clouds.yaml"` or `"secure.yaml"`
155
+ # @param env_var [String] the environment variable that overrides the
156
+ # search path for this file
157
+ # @return [Array(Hash, String), Array(Hash, nil)] the parsed document
158
+ # and the path it came from; an empty hash and nil if no file was
159
+ # found
160
+ # @raise [Kitchen::ActionFailed] if the file exists but is not valid
161
+ # YAML, or does not parse to a mapping
131
162
  def load_yaml_file(filename, env_var)
132
163
  paths = clouds_yaml_search_paths(filename, env_var)
133
164
  path = paths.find { |p| File.exist?(p) }
134
- return {} unless path
165
+ return [{}, nil] unless path
135
166
 
136
167
  debug "Loading #{filename} from #{path}"
137
- YAML.safe_load(File.read(path), permitted_classes: [Date]) || {} # rubocop: disable Style/YAMLFileRead
168
+ data = parse_yaml(path)
169
+
170
+ # A clouds.yaml that parses to a list or a scalar is a mistake worth
171
+ # naming, rather than a NoMethodError three frames later.
172
+ raise ActionFailed, "#{path} must contain a YAML mapping" unless data.is_a?(Hash)
173
+
174
+ [data, path]
138
175
  end
139
176
 
177
+ # Parses one YAML document, turning a syntax error into a message that
178
+ # names the offending file.
179
+ #
180
+ # @param path [String] the file to parse
181
+ # @return [Object] the parsed document
182
+ # @raise [Kitchen::ActionFailed] if the file is not valid YAML
183
+ def parse_yaml(path)
184
+ YAML.safe_load_file(path, permitted_classes: [Date]) || {}
185
+ rescue Psych::Exception => e
186
+ raise ActionFailed, "Could not parse #{path}: #{e.message}"
187
+ end
188
+
189
+ # Standard OpenStack client config search locations, highest priority
190
+ # first.
191
+ #
192
+ # @param filename [String] the file being looked for
193
+ # @param env_var [String] the environment variable that overrides it
194
+ # @return [Array<String>] candidate paths
140
195
  def clouds_yaml_search_paths(filename, env_var)
141
196
  paths = []
142
197
  paths << ENV[env_var] if ENV[env_var]
143
198
  paths << config[:clouds_yaml_path] if config[:clouds_yaml_path] && filename == "clouds.yaml"
144
199
  paths << File.join(Dir.pwd, filename)
145
- paths << File.join(Dir.home, ".config", "openstack", filename)
200
+ home = user_home
201
+ paths << File.join(home, ".config", "openstack", filename) if home
146
202
  paths << File.join("/etc/openstack", filename)
147
203
  paths
148
204
  end
149
205
 
150
- def extract_cloud(data, name)
151
- clouds = data["clouds"] || {}
206
+ # The user's home directory, or nil if there isn't one.
207
+ #
208
+ # `Dir.home` raises when HOME is unset and the uid has no passwd entry,
209
+ # which is the ordinary state inside a container running as an arbitrary
210
+ # uid. That must not take out the whole search -- /etc/openstack is
211
+ # still worth trying.
212
+ #
213
+ # @return [String, nil] the home directory, or nil if it cannot be
214
+ # determined
215
+ def user_home
216
+ Dir.home
217
+ rescue ArgumentError
218
+ nil
219
+ end
220
+
221
+ # Pulls one named cloud entry out of a parsed clouds/secure document.
222
+ #
223
+ # An absent entry is an empty hash: clouds.yaml and secure.yaml are
224
+ # merged, and it is normal for a cloud to appear in only one of them.
225
+ # An entry that is *present but not a mapping* is always a mistake, and
226
+ # is reported rather than silently discarded -- otherwise the driver
227
+ # carries on with nil credentials and the user sees an opaque Keystone
228
+ # auth failure that never mentions their config file.
229
+ #
230
+ # @param data [Hash] the parsed document
231
+ # @param name [String] the cloud name to extract
232
+ # @param source [String] the file the document came from, for errors
233
+ # @return [Hash] the cloud entry, or an empty hash if it is absent
234
+ # @raise [Kitchen::ActionFailed] if `clouds` or the named entry is
235
+ # present but is not a mapping
236
+ def extract_cloud(data, name, source)
237
+ clouds = data["clouds"]
238
+ return {} if clouds.nil?
239
+ raise ActionFailed, "The clouds section of #{source} must be a YAML mapping" unless clouds.is_a?(Hash)
240
+
152
241
  cloud = clouds[name]
153
- return {} unless cloud
242
+ return {} if cloud.nil?
243
+ raise ActionFailed, "Cloud <#{name}> in #{source} must be a YAML mapping" unless cloud.is_a?(Hash)
154
244
 
155
245
  cloud
156
246
  end
157
247
 
158
- # Deep merge two hashes (secure overrides clouds)
248
+ # Recursively merges `override` onto `base` without mutating either.
249
+ #
250
+ # @param base [Hash] the lower-priority hash
251
+ # @param override [Hash] the higher-priority hash
252
+ # @return [Hash] a new merged hash
159
253
  def deep_merge(base, override)
160
254
  result = base.dup
161
255
  override.each do |key, value|
@@ -168,19 +262,24 @@ module Kitchen
168
262
  result
169
263
  end
170
264
 
171
- # Convert a clouds.yaml cloud entry into Fog-compatible config keys
265
+ # Converts a clouds.yaml cloud entry into Fog-compatible config keys.
266
+ #
267
+ # @param cloud [Hash] a single cloud entry
268
+ # @return [Hash{Symbol => Object}] Fog config keys
172
269
  def translate_cloud_config(cloud)
173
270
  result = {}
174
271
 
175
272
  # Map auth section
176
273
  auth = cloud["auth"] || {}
177
274
  CLOUDS_YAML_AUTH_MAP.each do |yaml_key, fog_key|
178
- result[fog_key] = auth[yaml_key] if auth[yaml_key]
275
+ value = auth[yaml_key]
276
+ result[fog_key] = normalize_config_value(fog_key, value) if value
179
277
  end
180
278
 
181
279
  # Map top-level keys
182
280
  CLOUDS_YAML_TOP_MAP.each do |yaml_key, fog_key|
183
- result[fog_key] = cloud[yaml_key] if cloud[yaml_key]
281
+ value = cloud[yaml_key]
282
+ result[fog_key] = normalize_config_value(fog_key, value) if value
184
283
  end
185
284
 
186
285
  # SSL settings
@@ -189,6 +288,20 @@ module Kitchen
189
288
 
190
289
  result
191
290
  end
291
+
292
+ # Coerces values Fog insists on receiving as strings.
293
+ #
294
+ # YAML parses `identity_api_version: 3` and `project_id: 12345` as
295
+ # Integers, which Fog then fails on.
296
+ #
297
+ # @param fog_key [Symbol] the Fog config key
298
+ # @param value [Object] the raw value from YAML or the environment
299
+ # @return [Object] the value, stringified when the key requires it
300
+ def normalize_config_value(fog_key, value)
301
+ return value unless STRING_CONFIG_KEYS.include?(fog_key)
302
+
303
+ value.to_s
304
+ end
192
305
  end
193
306
  end
194
307
  end
@@ -21,14 +21,66 @@
21
21
  # See the License for the specific language governing permissions and
22
22
  # limitations under the License.
23
23
 
24
+ require "etc" unless defined?(Etc)
25
+ require "socket" unless defined?(Socket)
26
+
24
27
  module Kitchen
25
28
  module Driver
26
29
  class Openstack < Kitchen::Driver::Base
27
30
  # Server naming and configuration helpers
28
31
  module Config
29
- # Set the proper server name in the config
32
+ # Longest server name OpenStack will accept without truncating.
33
+ #
34
+ # Every other length in this file is derived from it, so the limit is
35
+ # actually maintained by the code rather than merely documented here.
36
+ #
37
+ # @return [Integer]
38
+ MAX_SERVER_NAME_LENGTH = 63
39
+
40
+ # Number of random characters appended to a user-supplied prefix.
41
+ #
42
+ # @return [Integer]
43
+ PREFIX_SUFFIX_LENGTH = 8
44
+
45
+ # Longest user-supplied prefix that still leaves room for the
46
+ # separator and the random suffix.
47
+ #
48
+ # @return [Integer]
49
+ MAX_PREFIX_LENGTH = MAX_SERVER_NAME_LENGTH - PREFIX_SUFFIX_LENGTH - 1
50
+
51
+ # Character budget for the instance name in a fully generated name.
52
+ #
53
+ # @return [Integer]
54
+ NAME_INSTANCE_LENGTH = 15
55
+
56
+ # Character budget for the username in a fully generated name.
57
+ #
58
+ # @return [Integer]
59
+ NAME_USERNAME_LENGTH = 15
60
+
61
+ # Number of random characters in a fully generated name.
62
+ #
63
+ # @return [Integer]
64
+ NAME_RANDOM_LENGTH = 7
65
+
66
+ # Character budget for the hostname in a fully generated name.
67
+ #
68
+ # Whatever is left once the other three components and the three
69
+ # separators are accounted for.
70
+ #
71
+ # @return [Integer]
72
+ NAME_HOSTNAME_LENGTH =
73
+ MAX_SERVER_NAME_LENGTH - NAME_INSTANCE_LENGTH - NAME_USERNAME_LENGTH - NAME_RANDOM_LENGTH - 3
74
+
75
+ # Sets `config[:server_name]` unless the user already supplied one.
76
+ #
77
+ # Called at the top of {Kitchen::Driver::Openstack#create} rather than
78
+ # at config-finalize time so that the random suffix is generated once
79
+ # per converge instead of once per `kitchen` invocation.
80
+ #
81
+ # @return [String] the resolved server name
30
82
  def config_server_name
31
- return if config[:server_name]
83
+ return config[:server_name] if config[:server_name]
32
84
 
33
85
  config[:server_name] = if config[:server_name_prefix]
34
86
  server_name_prefix(config[:server_name_prefix])
@@ -39,46 +91,61 @@ module Kitchen
39
91
 
40
92
  private
41
93
 
42
- # Generate what should be a unique server name up to 63 total chars
43
- # Base name: 15
44
- # Username: 15
45
- # Hostname: 23
46
- # Random string: 7
47
- # Separators: 3
48
- # ================
49
- # Total: 63
94
+ # Generates a unique server name of at most 63 characters.
95
+ #
96
+ # <instance>-<user>-<host>-<random>
97
+ # 15 15 23 7 + 3 separators = 63
98
+ #
99
+ # @return [String] the generated server name
50
100
  def default_name
51
101
  [
52
- instance.name.gsub(/\W/, "")[0..14],
53
- ((Etc.getpwuid ? Etc.getpwuid.name : Etc.getlogin) || "nologin").gsub(/\W/, "")[0..14],
54
- Socket.gethostname.gsub(/\W/, "")[0..22],
55
- Array.new(7) { rand(36).to_s(36) }.join,
102
+ instance.name.gsub(/\W/, "")[0, NAME_INSTANCE_LENGTH],
103
+ current_username.gsub(/\W/, "")[0, NAME_USERNAME_LENGTH],
104
+ Socket.gethostname.gsub(/\W/, "")[0, NAME_HOSTNAME_LENGTH],
105
+ Array.new(NAME_RANDOM_LENGTH) { rand(36).to_s(36) }.join,
56
106
  ].join("-")
57
107
  end
58
108
 
109
+ # Best-effort lookup of the name of the user running Test Kitchen.
110
+ #
111
+ # `Etc.getpwuid` raises `ArgumentError` rather than returning nil when
112
+ # the effective uid has no passwd entry, which is routine inside
113
+ # containers, so both failure modes fall through to `Etc.getlogin` and
114
+ # finally to a placeholder.
115
+ #
116
+ # @return [String] a username, or `"nologin"` if none can be determined
117
+ def current_username
118
+ (Etc.getpwuid&.name || Etc.getlogin || "nologin")
119
+ rescue ArgumentError
120
+ Etc.getlogin || "nologin"
121
+ end
122
+
123
+ # Generates a unique server name from a user-supplied prefix.
124
+ #
125
+ # <prefix>-<random>
126
+ # max 54 8 + 1 separator = 63
127
+ #
128
+ # Falls back to {#default_name} when the prefix contains nothing usable
129
+ # once non-word characters are stripped.
130
+ #
131
+ # @param server_name_prefix [String] the configured prefix; never
132
+ # mutated, so the caller's config survives intact
133
+ # @return [String] the generated server name
59
134
  def server_name_prefix(server_name_prefix)
60
- # Generate what should be a unique server name with given prefix
61
- # of up to 63 total chars
62
- #
63
- # Provided prefix: variable, max 54
64
- # Separator: 1
65
- # Random string: 8
66
- # ===================
67
- # Max: 63
68
- #
69
- if server_name_prefix.length > 54
70
- warn "Server name prefix too long, truncated to 54 characters"
71
- server_name_prefix = server_name_prefix[0..53]
135
+ prefix = server_name_prefix.to_s
136
+ if prefix.length > MAX_PREFIX_LENGTH
137
+ warn "Server name prefix too long, truncated to #{MAX_PREFIX_LENGTH} characters"
138
+ prefix = prefix[0, MAX_PREFIX_LENGTH]
72
139
  end
73
140
 
74
- server_name_prefix.gsub!(/\W/, "")
141
+ prefix = prefix.gsub(/\W/, "")
75
142
 
76
- if server_name_prefix.empty?
143
+ if prefix.empty?
77
144
  warn "Server name prefix empty or invalid; using fully generated name"
78
145
  default_name
79
146
  else
80
- random_suffix = ("a".."z").to_a.sample(8).join
81
- server_name_prefix + "-" + random_suffix
147
+ random_suffix = ("a".."z").to_a.sample(PREFIX_SUFFIX_LENGTH).join
148
+ "#{prefix}-#{random_suffix}"
82
149
  end
83
150
  end
84
151
  end
@@ -28,8 +28,20 @@ module Kitchen
28
28
  class Openstack < Kitchen::Driver::Base
29
29
  # Ohai hints, SSL handling, and server wait helpers
30
30
  module Helpers
31
+ # Seconds between progress dots while counting down.
32
+ #
33
+ # @return [Integer]
34
+ COUNTDOWN_TICK = 10
35
+
31
36
  private
32
37
 
38
+ # Drops an empty `openstack.json` ohai hint on the new instance so
39
+ # that Chef Infra's ohai run picks up OpenStack metadata.
40
+ #
41
+ # Does nothing on platforms that are neither Bourne-shell nor Windows.
42
+ #
43
+ # @param state [Hash] instance state, used to open a transport session
44
+ # @return [void]
33
45
  def add_ohai_hint(state)
34
46
  if bourne_shell?
35
47
  info "Adding OpenStack hint for ohai"
@@ -45,18 +57,40 @@ module Kitchen
45
57
  instance.transport.connection(state).execute(
46
58
  "#{touch_cmd} #{touch_cmd_args}"
47
59
  )
60
+ else
61
+ debug "Unknown platform shell; skipping the OpenStack ohai hint"
48
62
  end
49
63
  end
50
64
 
65
+ # The directory ohai reads hint files from.
66
+ #
67
+ # @return [String] the first configured ohai hints path
51
68
  def hints_path
52
69
  Ohai.config[:hints_path][0]
53
70
  end
54
71
 
72
+ # Turns off TLS peer verification for every subsequent Excon request in
73
+ # this process.
74
+ #
75
+ # Called when the `disable_ssl_validation` config key is set, which the
76
+ # user sets directly in kitchen.yml or which is inferred from a
77
+ # `verify: false` entry in clouds.yaml.
78
+ #
79
+ # @return [void]
55
80
  def disable_ssl_validation
56
81
  require "excon" unless defined?(Excon)
57
82
  Excon.defaults[:ssl_verify_peer] = false
58
83
  end
59
84
 
85
+ # Blocks until the transport reports the instance is reachable,
86
+ # destroying the instance if it never comes up.
87
+ #
88
+ # A server we cannot reach is a server we cannot clean up later, so the
89
+ # failure path tears it down before re-raising rather than leaking it.
90
+ #
91
+ # @param state [Hash] instance state, containing `:hostname`
92
+ # @return [void]
93
+ # @raise [StandardError] whatever the transport raised, after cleanup
60
94
  def wait_for_server(state)
61
95
  if config[:server_wait]
62
96
  info "Sleeping for #{config[:server_wait]} seconds to let your server start up..."
@@ -64,17 +98,22 @@ module Kitchen
64
98
  end
65
99
  info "Waiting for server to be ready..."
66
100
  instance.transport.connection(state).wait_until_ready
67
- rescue
68
- error "Server #{state[:hostname]} (#{state[:server_id]}) not reachable. Destroying server..."
101
+ rescue => e
102
+ error "Server #{state[:hostname]} (#{state[:server_id]}) not reachable: #{e.message}. Destroying server..."
69
103
  destroy(state)
70
104
  raise
71
105
  end
72
106
 
107
+ # Prints a progress dot every {COUNTDOWN_TICK} seconds for the given
108
+ # duration.
109
+ #
110
+ # @param seconds [Integer] how long to wait
111
+ # @return [void]
73
112
  def countdown(seconds)
74
- date1 = Time.now + seconds
75
- while Time.now < date1
113
+ finish_at = Time.now + seconds
114
+ while Time.now < finish_at
76
115
  Kernel.print "."
77
- sleep 10
116
+ sleep COUNTDOWN_TICK
78
117
  end
79
118
  end
80
119
  end