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.
@@ -76,9 +76,12 @@ module Kitchen
76
76
  private
77
77
 
78
78
  # Merges external config sources into the driver config hash.
79
- # Precedence: kitchen.yml > OS_* env vars > clouds.yaml
80
- # Only sets keys that are currently nil so that kitchen.yml
81
- # 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]
82
85
  def apply_clouds_config
83
86
  cc = load_clouds_config
84
87
  env = load_env_vars
@@ -91,15 +94,23 @@ module Kitchen
91
94
  config[key] = value if config[key].nil?
92
95
  end
93
96
 
94
- # Apply SSL settings: env vars or clouds.yaml disabling verification
95
- ssl_verify = env.key?(:ssl_verify_peer) ? env[:ssl_verify_peer] : cc[:ssl_verify_peer]
96
- 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]
97
103
 
98
104
  config[:disable_ssl_validation] = true
99
105
  end
100
106
 
101
- # Reads OS_* environment variables and maps them to Fog config keys.
102
- # 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
103
114
  def load_env_vars
104
115
  result = {}
105
116
  ENV_VAR_MAP.each do |env_var, fog_key|
@@ -109,57 +120,136 @@ module Kitchen
109
120
  result
110
121
  end
111
122
 
112
- # 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
113
126
  def cloud_name
114
127
  config[:openstack_cloud] || ENV["OS_CLOUD"]
115
128
  end
116
129
 
117
130
  # Loads and merges clouds.yaml with secure.yaml, then translates the
118
131
  # named cloud entry into Fog-compatible config keys.
119
- # 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
120
137
  def load_clouds_config
121
138
  name = cloud_name
122
139
  return {} unless name
123
140
 
124
- clouds_data = load_yaml_file("clouds.yaml", "OS_CLIENT_CONFIG_FILE")
125
- 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")
126
143
 
127
- cloud = extract_cloud(clouds_data, name)
128
- secure = extract_cloud(secure_data, name)
144
+ cloud = extract_cloud(clouds_data, name, clouds_path)
145
+ secure = extract_cloud(secure_data, name, secure_path)
129
146
 
130
147
  cloud = deep_merge(cloud, secure)
131
148
  translate_cloud_config(cloud)
132
149
  end
133
150
 
134
- # 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
135
162
  def load_yaml_file(filename, env_var)
136
163
  paths = clouds_yaml_search_paths(filename, env_var)
137
164
  path = paths.find { |p| File.exist?(p) }
138
- return {} unless path
165
+ return [{}, nil] unless path
139
166
 
140
167
  debug "Loading #{filename} from #{path}"
141
- 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]
175
+ end
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}"
142
187
  end
143
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
144
195
  def clouds_yaml_search_paths(filename, env_var)
145
196
  paths = []
146
197
  paths << ENV[env_var] if ENV[env_var]
147
198
  paths << config[:clouds_yaml_path] if config[:clouds_yaml_path] && filename == "clouds.yaml"
148
199
  paths << File.join(Dir.pwd, filename)
149
- paths << File.join(Dir.home, ".config", "openstack", filename)
200
+ home = user_home
201
+ paths << File.join(home, ".config", "openstack", filename) if home
150
202
  paths << File.join("/etc/openstack", filename)
151
203
  paths
152
204
  end
153
205
 
154
- def extract_cloud(data, name)
155
- 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
+
156
241
  cloud = clouds[name]
157
- 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)
158
244
 
159
245
  cloud
160
246
  end
161
247
 
162
- # 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
163
253
  def deep_merge(base, override)
164
254
  result = base.dup
165
255
  override.each do |key, value|
@@ -172,7 +262,10 @@ module Kitchen
172
262
  result
173
263
  end
174
264
 
175
- # 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
176
269
  def translate_cloud_config(cloud)
177
270
  result = {}
178
271
 
@@ -196,6 +289,14 @@ module Kitchen
196
289
  result
197
290
  end
198
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
199
300
  def normalize_config_value(fog_key, value)
200
301
  return value unless STRING_CONFIG_KEYS.include?(fog_key)
201
302
 
@@ -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