diff --git a/.gitignore b/.gitignore
index c4d0d4e..ef442c3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,4 +20,5 @@ tmp
*~
.direnv/
.envrc
-.ruby-version
\ No newline at end of file
+.ruby-version
+.ruby-gemset
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index 4a36a5b..1b3f530 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -14,9 +14,15 @@ kitchen-openstack is a Test Kitchen driver for OpenStack. It provisions and dest
## Architecture
- Driver class: `Kitchen::Driver::Openstack` in `lib/kitchen/driver/openstack.rb` — extends `Kitchen::Driver::Base` (Driver API v2)
+- Clouds.yaml support: `Kitchen::Driver::Openstack::Clouds` in `lib/kitchen/driver/openstack/clouds.rb` — parses OpenStack `clouds.yaml`/`secure.yaml` and translates to Fog config
+- Server configuration: `Kitchen::Driver::Openstack::Config` in `lib/kitchen/driver/openstack/config.rb` — server naming helpers
+- Helpers: `Kitchen::Driver::Openstack::Helpers` in `lib/kitchen/driver/openstack/helpers.rb` — ohai hints, SSL, server wait
+- Networking: `Kitchen::Driver::Openstack::Networking` in `lib/kitchen/driver/openstack/networking.rb` — floating IP allocation, IP resolution
+- Server creation: `Kitchen::Driver::Openstack::ServerHelper` in `lib/kitchen/driver/openstack/server_helper.rb` — server creation, image/flavor/network finders
- Volume handling: `Kitchen::Driver::Openstack::Volume` in `lib/kitchen/driver/openstack/volume.rb`
- Version constant: `OPENSTACK_VERSION` in `lib/kitchen/driver/openstack_version.rb` — used by gemspec and release automation
- Configuration uses `default_config` declarations; raise `Kitchen::ActionFailed` for driver errors
+- Supports `clouds.yaml` via `openstack_cloud` config or `OS_CLOUD` env var — see `Clouds` module
## Build and Test
diff --git a/README.md b/README.md
index c188733..44a247d 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# Kitchen::OpenStack

-
+
A Test Kitchen Driver for OpenStack.
@@ -11,7 +11,7 @@ Shamelessly copied from [Fletcher Nichol](https://github.com/fnichol)'s awesome
## Status
-This software project is no longer under active development as it has no active maintainers. The software may continue to work for some or all use cases, but issues filed in GitHub will most likely not be triaged. If a new maintainer is interested in working on this project please come chat with us in #test-kitchen on Chef Community Slack.
+This software project is actively maintained by the [OSU Open Source Lab](https://osuosl.org/).
## Requirements
@@ -47,6 +47,125 @@ gem install kitchen-openstack
See for documentation.
+### Using `clouds.yaml`
+
+This driver supports OpenStack's standard
+[`clouds.yaml`](https://docs.openstack.org/python-openstackclient/latest/configuration/index.html)
+client configuration file. This allows you to use the same credentials and
+endpoint configuration that other OpenStack tools (like the `openstack` CLI)
+already use, instead of duplicating them in `kitchen.yml`.
+
+The driver searches for `clouds.yaml` in the standard locations:
+
+1. `OS_CLIENT_CONFIG_FILE` environment variable (if set)
+2. `clouds_yaml_path` driver config option (if set)
+3. Current directory (`./clouds.yaml`)
+4. `~/.config/openstack/clouds.yaml`
+5. `/etc/openstack/clouds.yaml`
+
+The first file found is used. A `secure.yaml` file in the same search
+locations is also loaded and merged, so you can split secrets out of
+`clouds.yaml` following the
+[standard convention](https://docs.openstack.org/openstacksdk/latest/user/config/configuration.html#splitting-secrets).
+
+#### Selecting a cloud
+
+Specify which cloud entry to use in one of two ways:
+
+- Set `openstack_cloud` in `kitchen.yml` (takes precedence)
+- Set the `OS_CLOUD` environment variable
+
+#### Example `kitchen.yml`
+
+```yaml
+driver:
+ name: openstack
+ openstack_cloud: mycloud
+ image_ref: ubuntu-22.04
+ flavor_ref: m1.small
+ key_name: my-keypair
+```
+
+Or, relying entirely on `OS_CLOUD`:
+
+```bash
+export OS_CLOUD=mycloud
+```
+
+```yaml
+driver:
+ name: openstack
+ image_ref: ubuntu-22.04
+ flavor_ref: m1.small
+ key_name: my-keypair
+```
+
+Settings specified in `kitchen.yml` always take precedence over values from
+`clouds.yaml`. For example, you can override just the region:
+
+```yaml
+driver:
+ name: openstack
+ openstack_cloud: mycloud
+ openstack_region: RegionTwo
+```
+
+#### Using `OS_*` environment variables
+
+The driver recognizes the standard OpenStack `OS_*` environment variables
+(e.g. from an `openrc` file). This means you can source your OpenStack
+credentials and use them directly without any extra configuration in
+`kitchen.yml`:
+
+```bash
+source openrc.sh
+```
+
+```yaml
+driver:
+ name: openstack
+ image_ref: ubuntu-22.04
+ flavor_ref: m1.small
+ key_name: my-keypair
+```
+
+The supported environment variables are:
+
+| Env var | Maps to |
+|---|---|
+| `OS_AUTH_URL` | `openstack_auth_url` |
+| `OS_USERNAME` | `openstack_username` |
+| `OS_PASSWORD` | `openstack_api_key` |
+| `OS_PROJECT_NAME` | `openstack_project_name` |
+| `OS_PROJECT_ID` | `openstack_project_id` |
+| `OS_USER_DOMAIN_NAME` | `openstack_user_domain` |
+| `OS_USER_DOMAIN_ID` | `openstack_user_domain_id` |
+| `OS_PROJECT_DOMAIN_NAME` | `openstack_project_domain` |
+| `OS_PROJECT_DOMAIN_ID` | `openstack_project_domain_id` |
+| `OS_DOMAIN_ID` | `openstack_domain_id` |
+| `OS_DOMAIN_NAME` | `openstack_domain_name` |
+| `OS_REGION_NAME` | `openstack_region` |
+| `OS_INTERFACE` | `openstack_endpoint_type` |
+| `OS_IDENTITY_API_VERSION` | `openstack_identity_api_version` |
+| `OS_APPLICATION_CREDENTIAL_ID` | `openstack_application_credential_id` |
+| `OS_APPLICATION_CREDENTIAL_SECRET` | `openstack_application_credential_secret` |
+| `OS_CACERT` | `ssl_ca_file` |
+
+#### Configuration precedence
+
+The driver follows the upstream OpenStack SDK precedence order:
+
+1. **`kitchen.yml`** — explicit driver config always wins
+2. **`OS_*` env vars** — override `clouds.yaml` values
+3. **`clouds.yaml`** (merged with `secure.yaml`) — base configuration
+
+#### New driver config options
+
+| Option | Default | Description |
+|---|---|---|
+| `openstack_cloud` | `nil` | Name of the cloud entry in `clouds.yaml`. Falls back to the `OS_CLOUD` env var. |
+| `clouds_yaml_path` | `nil` | Explicit path to a `clouds.yaml` file, inserted into the search path. |
+
## Development
Pull requests are very welcome! Make sure your patches are well tested.
diff --git a/lib/kitchen/driver/openstack.rb b/lib/kitchen/driver/openstack.rb
index 3067b41..e7c9b7d 100755
--- a/lib/kitchen/driver/openstack.rb
+++ b/lib/kitchen/driver/openstack.rb
@@ -25,6 +25,7 @@
require "fog/openstack"
require "yaml"
require_relative "openstack_version"
+require_relative "openstack/clouds"
require_relative "openstack/config"
require_relative "openstack/helpers"
require_relative "openstack/networking"
@@ -35,6 +36,7 @@ module Kitchen
module Driver
# This takes from the Base Class and creates the OpenStack driver.
class Openstack < Kitchen::Driver::Base
+ include Clouds
include Config
include Helpers
include Networking
@@ -43,7 +45,17 @@ class Openstack < Kitchen::Driver::Base
kitchen_driver_api_version 2
plugin_version Kitchen::Driver::OPENSTACK_VERSION
+ default_config :openstack_cloud, nil
+ default_config :clouds_yaml_path, nil
default_config :server_name, nil
+
+ # Merge clouds.yaml values into config so they are visible in
+ # `kitchen diagnose` and available to all driver methods.
+ def finalize_config!(instance)
+ super
+ apply_clouds_config
+ self
+ end
default_config :server_name_prefix, nil
default_config :key_name, nil
default_config :port, "22"
@@ -84,7 +96,10 @@ def create(state)
debug "Waiting for a max time of:#{config[:glance_cache_wait_timeout]} seconds for OpenStack server to be in ACTIVE state"
server.wait_for(config[:glance_cache_wait_timeout]) do
sleep(1)
- raise(Kitchen::InstanceFailure, "OpenStack server ID <#{state[:server_id]}> build failed to ERROR state") if failed?
+ if failed?
+ raise(Kitchen::InstanceFailure,
+ "OpenStack server ID <#{state[:server_id]}> build failed to ERROR state")
+ end
ready?
end
@@ -98,8 +113,8 @@ def create(state)
state[:hostname] = get_ip(server)
wait_for_server(state)
add_ohai_hint(state)
- rescue Fog::Errors::Error, Excon::Errors::Error => ex
- raise ActionFailed, ex.message
+ rescue Fog::Errors::Error, Excon::Errors::Error => e
+ raise ActionFailed, e.message
end
def destroy(state)
diff --git a/lib/kitchen/driver/openstack/clouds.rb b/lib/kitchen/driver/openstack/clouds.rb
new file mode 100644
index 0000000..92d2c4b
--- /dev/null
+++ b/lib/kitchen/driver/openstack/clouds.rb
@@ -0,0 +1,195 @@
+# frozen_string_literal: true
+
+#
+# Author:: Lance Albertson ()
+#
+# Copyright:: (C) 2026, Oregon State University
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require "yaml"
+
+module Kitchen
+ module Driver
+ class Openstack < Kitchen::Driver::Base
+ # Support for OpenStack clouds.yaml client configuration
+ module Clouds
+ # Mapping of clouds.yaml auth keys to Fog OpenStack config keys
+ CLOUDS_YAML_AUTH_MAP = {
+ "auth_url" => :openstack_auth_url,
+ "username" => :openstack_username,
+ "password" => :openstack_api_key,
+ "project_name" => :openstack_project_name,
+ "project_id" => :openstack_project_id,
+ "user_domain_name" => :openstack_user_domain,
+ "user_domain_id" => :openstack_user_domain_id,
+ "project_domain_name" => :openstack_project_domain,
+ "project_domain_id" => :openstack_project_domain_id,
+ "domain_id" => :openstack_domain_id,
+ "domain_name" => :openstack_domain_name,
+ "application_credential_id" => :openstack_application_credential_id,
+ "application_credential_secret" => :openstack_application_credential_secret,
+ }.freeze
+
+ # Mapping of clouds.yaml top-level keys to Fog OpenStack config keys
+ CLOUDS_YAML_TOP_MAP = {
+ "region_name" => :openstack_region,
+ "interface" => :openstack_endpoint_type,
+ "identity_api_version" => :openstack_identity_api_version,
+ }.freeze
+
+ # Mapping of OS_* environment variables to Fog OpenStack config keys
+ ENV_VAR_MAP = {
+ "OS_AUTH_URL" => :openstack_auth_url,
+ "OS_USERNAME" => :openstack_username,
+ "OS_PASSWORD" => :openstack_api_key,
+ "OS_PROJECT_NAME" => :openstack_project_name,
+ "OS_PROJECT_ID" => :openstack_project_id,
+ "OS_USER_DOMAIN_NAME" => :openstack_user_domain,
+ "OS_USER_DOMAIN_ID" => :openstack_user_domain_id,
+ "OS_PROJECT_DOMAIN_NAME" => :openstack_project_domain,
+ "OS_PROJECT_DOMAIN_ID" => :openstack_project_domain_id,
+ "OS_DOMAIN_ID" => :openstack_domain_id,
+ "OS_DOMAIN_NAME" => :openstack_domain_name,
+ "OS_REGION_NAME" => :openstack_region,
+ "OS_INTERFACE" => :openstack_endpoint_type,
+ "OS_IDENTITY_API_VERSION" => :openstack_identity_api_version,
+ "OS_APPLICATION_CREDENTIAL_ID" => :openstack_application_credential_id,
+ "OS_APPLICATION_CREDENTIAL_SECRET" => :openstack_application_credential_secret,
+ "OS_CACERT" => :ssl_ca_file,
+ }.freeze
+
+ private
+
+ # Merges external config sources into the driver config hash.
+ # Precedence: kitchen.yml > OS_* env vars > clouds.yaml
+ # Only sets keys that are currently nil so that kitchen.yml
+ # values always take precedence.
+ def apply_clouds_config
+ cc = load_clouds_config
+ env = load_env_vars
+
+ # env vars override clouds.yaml per upstream openstacksdk precedence
+ merged = cc.merge(env)
+ return if merged.empty?
+
+ merged.each do |key, value|
+ config[key] = value if config[key].nil?
+ end
+
+ # Apply SSL settings: env vars or clouds.yaml disabling verification
+ ssl_verify = env.key?(:ssl_verify_peer) ? env[:ssl_verify_peer] : cc[:ssl_verify_peer]
+ return unless ssl_verify == false && !config[:disable_ssl_validation]
+
+ config[:disable_ssl_validation] = true
+ end
+
+ # Reads OS_* environment variables and maps them to Fog config keys.
+ # Returns a hash of fog config symbols for any set env vars.
+ def load_env_vars
+ result = {}
+ ENV_VAR_MAP.each do |env_var, fog_key|
+ value = ENV[env_var]
+ result[fog_key] = value if value && !value.empty?
+ end
+ result
+ end
+
+ # Resolves the cloud name from config or the OS_CLOUD environment variable
+ def cloud_name
+ config[:openstack_cloud] || ENV["OS_CLOUD"]
+ end
+
+ # Loads and merges clouds.yaml with secure.yaml, then translates the
+ # named cloud entry into Fog-compatible config keys.
+ # Returns a hash of fog config symbols, or empty hash if no cloud configured.
+ def load_clouds_config
+ name = cloud_name
+ return {} unless name
+
+ clouds_data = load_yaml_file("clouds.yaml", "OS_CLIENT_CONFIG_FILE")
+ secure_data = load_yaml_file("secure.yaml", "OS_CLIENT_SECURE_FILE")
+
+ cloud = extract_cloud(clouds_data, name)
+ secure = extract_cloud(secure_data, name)
+
+ cloud = deep_merge(cloud, secure)
+ translate_cloud_config(cloud)
+ end
+
+ # Search standard OpenStack config file locations for the given filename
+ def load_yaml_file(filename, env_var)
+ paths = clouds_yaml_search_paths(filename, env_var)
+ path = paths.find { |p| File.exist?(p) }
+ return {} unless path
+
+ debug "Loading #{filename} from #{path}"
+ YAML.safe_load(File.read(path), permitted_classes: [Date]) || {} # rubocop: disable Style/YAMLFileRead
+ end
+
+ def clouds_yaml_search_paths(filename, env_var)
+ paths = []
+ paths << ENV[env_var] if ENV[env_var]
+ paths << config[:clouds_yaml_path] if config[:clouds_yaml_path] && filename == "clouds.yaml"
+ paths << File.join(Dir.pwd, filename)
+ paths << File.join(Dir.home, ".config", "openstack", filename)
+ paths << File.join("/etc/openstack", filename)
+ paths
+ end
+
+ def extract_cloud(data, name)
+ clouds = data["clouds"] || {}
+ cloud = clouds[name]
+ return {} unless cloud
+
+ cloud
+ end
+
+ # Deep merge two hashes (secure overrides clouds)
+ def deep_merge(base, override)
+ result = base.dup
+ override.each do |key, value|
+ result[key] = if result[key].is_a?(Hash) && value.is_a?(Hash)
+ deep_merge(result[key], value)
+ else
+ value
+ end
+ end
+ result
+ end
+
+ # Convert a clouds.yaml cloud entry into Fog-compatible config keys
+ def translate_cloud_config(cloud)
+ result = {}
+
+ # Map auth section
+ auth = cloud["auth"] || {}
+ CLOUDS_YAML_AUTH_MAP.each do |yaml_key, fog_key|
+ result[fog_key] = auth[yaml_key] if auth[yaml_key]
+ end
+
+ # Map top-level keys
+ CLOUDS_YAML_TOP_MAP.each do |yaml_key, fog_key|
+ result[fog_key] = cloud[yaml_key] if cloud[yaml_key]
+ end
+
+ # SSL settings
+ result[:ssl_verify_peer] = cloud["verify"] if cloud.key?("verify")
+ result[:ssl_ca_file] = cloud["cacert"] if cloud["cacert"]
+
+ result
+ end
+ end
+ end
+ end
+end
diff --git a/spec/kitchen/driver/openstack/clouds_spec.rb b/spec/kitchen/driver/openstack/clouds_spec.rb
new file mode 100644
index 0000000..1bae21c
--- /dev/null
+++ b/spec/kitchen/driver/openstack/clouds_spec.rb
@@ -0,0 +1,750 @@
+# frozen_string_literal: true
+
+require_relative "../../../spec_helper"
+require_relative "../../../../lib/kitchen/driver/openstack"
+
+require "logger"
+require "stringio" unless defined?(StringIO)
+require "rspec"
+require "kitchen"
+require "kitchen/driver/openstack"
+require "kitchen/provisioner/dummy"
+require "kitchen/transport/dummy"
+require "kitchen/verifier/dummy"
+
+describe Kitchen::Driver::Openstack do
+ let(:logged_output) { StringIO.new }
+ let(:logger) { Logger.new(logged_output) }
+ let(:config) { {} }
+ let(:instance_name) { "potatoes" }
+ let(:transport) { Kitchen::Transport::Dummy.new }
+ let(:platform) { Kitchen::Platform.new(name: "fake_platform") }
+ let(:driver) { described_class.new(config) }
+
+ let(:instance) do
+ double(
+ name: instance_name,
+ transport: transport,
+ logger: logger,
+ platform: platform,
+ to_str: "instance"
+ )
+ end
+
+ before(:each) do
+ allow_any_instance_of(described_class).to receive(:instance)
+ .and_return(instance)
+ allow(File).to receive(:exist?).and_call_original
+ end
+
+ let(:clouds_yaml_content) do
+ {
+ "clouds" => {
+ "mycloud" => {
+ "auth" => {
+ "auth_url" => "https://keystone.example.com:5000/v3",
+ "username" => "testuser",
+ "password" => "testpass",
+ "project_name" => "testproject",
+ "user_domain_name" => "Default",
+ "project_domain_name" => "Default",
+ "domain_id" => "default",
+ },
+ "region_name" => "RegionOne",
+ "interface" => "public",
+ "identity_api_version" => "3",
+ },
+ "minimal" => {
+ "auth" => {
+ "auth_url" => "https://minimal.example.com:5000/v3",
+ "username" => "minuser",
+ "password" => "minpass",
+ "domain_id" => "default",
+ },
+ },
+ "appcred" => {
+ "auth" => {
+ "auth_url" => "https://appcred.example.com:5000/v3",
+ "application_credential_id" => "abc123",
+ "application_credential_secret" => "secret456",
+ "domain_id" => "default",
+ },
+ "auth_type" => "v3applicationcredential",
+ },
+ "sslcloud" => {
+ "auth" => {
+ "auth_url" => "https://ssl.example.com:5000/v3",
+ "username" => "ssluser",
+ "password" => "sslpass",
+ "domain_id" => "default",
+ },
+ "verify" => false,
+ "cacert" => "/path/to/ca.crt",
+ },
+ },
+ }
+ end
+
+ let(:secure_yaml_content) do
+ {
+ "clouds" => {
+ "mycloud" => {
+ "auth" => {
+ "password" => "secure_password_override",
+ },
+ },
+ },
+ }
+ end
+
+ describe "#cloud_name" do
+ context "when openstack_cloud is set in config" do
+ let(:config) { { openstack_cloud: "mycloud" } }
+
+ it "returns the config value" do
+ expect(driver.send(:cloud_name)).to eq("mycloud")
+ end
+ end
+
+ context "when OS_CLOUD env var is set" do
+ before { allow(ENV).to receive(:[]).and_call_original }
+ before { allow(ENV).to receive(:[]).with("OS_CLOUD").and_return("envcloud") }
+
+ it "returns the env var value" do
+ expect(driver.send(:cloud_name)).to eq("envcloud")
+ end
+ end
+
+ context "when openstack_cloud config takes precedence over OS_CLOUD" do
+ let(:config) { { openstack_cloud: "configcloud" } }
+
+ before { allow(ENV).to receive(:[]).and_call_original }
+ before { allow(ENV).to receive(:[]).with("OS_CLOUD").and_return("envcloud") }
+
+ it "returns the config value" do
+ expect(driver.send(:cloud_name)).to eq("configcloud")
+ end
+ end
+
+ context "when neither is set" do
+ before { allow(ENV).to receive(:[]).and_call_original }
+ before { allow(ENV).to receive(:[]).with("OS_CLOUD").and_return(nil) }
+
+ it "returns nil" do
+ expect(driver.send(:cloud_name)).to be_nil
+ end
+ end
+ end
+
+ describe "#load_clouds_config" do
+ before do
+ allow(ENV).to receive(:[]).and_call_original
+ allow(ENV).to receive(:[]).with("OS_CLIENT_CONFIG_FILE").and_return(nil)
+ allow(ENV).to receive(:[]).with("OS_CLIENT_SECURE_FILE").and_return(nil)
+ allow(ENV).to receive(:[]).with("OS_CLOUD").and_return(nil)
+ end
+
+ context "when no cloud name is configured" do
+ it "returns an empty hash" do
+ expect(driver.send(:load_clouds_config)).to eq({})
+ end
+ end
+
+ context "when a cloud name is set and clouds.yaml exists" do
+ let(:config) { { openstack_cloud: "mycloud" } }
+
+ before do
+ allow(File).to receive(:exist?).and_return(false)
+ allow(File).to receive(:exist?)
+ .with(File.join(Dir.pwd, "clouds.yaml")).and_return(true)
+ allow(File).to receive(:read)
+ .with(File.join(Dir.pwd, "clouds.yaml"))
+ .and_return(YAML.dump(clouds_yaml_content))
+ end
+
+ it "returns translated fog config" do
+ result = driver.send(:load_clouds_config)
+ expect(result[:openstack_auth_url]).to eq("https://keystone.example.com:5000/v3")
+ expect(result[:openstack_username]).to eq("testuser")
+ expect(result[:openstack_api_key]).to eq("testpass")
+ expect(result[:openstack_project_name]).to eq("testproject")
+ expect(result[:openstack_user_domain]).to eq("Default")
+ expect(result[:openstack_project_domain]).to eq("Default")
+ expect(result[:openstack_domain_id]).to eq("default")
+ expect(result[:openstack_region]).to eq("RegionOne")
+ expect(result[:openstack_endpoint_type]).to eq("public")
+ expect(result[:openstack_identity_api_version]).to eq("3")
+ end
+ end
+
+ context "when secure.yaml provides password override" do
+ let(:config) { { openstack_cloud: "mycloud" } }
+
+ before do
+ allow(File).to receive(:exist?).and_return(false)
+ allow(File).to receive(:exist?)
+ .with(File.join(Dir.pwd, "clouds.yaml")).and_return(true)
+ allow(File).to receive(:exist?)
+ .with(File.join(Dir.pwd, "secure.yaml")).and_return(true)
+ allow(File).to receive(:read)
+ .with(File.join(Dir.pwd, "clouds.yaml"))
+ .and_return(YAML.dump(clouds_yaml_content))
+ allow(File).to receive(:read)
+ .with(File.join(Dir.pwd, "secure.yaml"))
+ .and_return(YAML.dump(secure_yaml_content))
+ end
+
+ it "merges secure.yaml values over clouds.yaml" do
+ result = driver.send(:load_clouds_config)
+ expect(result[:openstack_api_key]).to eq("secure_password_override")
+ expect(result[:openstack_username]).to eq("testuser")
+ end
+ end
+
+ context "when OS_CLIENT_CONFIG_FILE is set" do
+ let(:config) { { openstack_cloud: "mycloud" } }
+ let(:custom_path) { "/custom/path/clouds.yaml" }
+
+ before do
+ allow(ENV).to receive(:[]).with("OS_CLIENT_CONFIG_FILE").and_return(custom_path)
+ allow(File).to receive(:exist?).and_return(false)
+ allow(File).to receive(:exist?).with(custom_path).and_return(true)
+ allow(File).to receive(:read)
+ .with(custom_path)
+ .and_return(YAML.dump(clouds_yaml_content))
+ end
+
+ it "uses the custom path" do
+ result = driver.send(:load_clouds_config)
+ expect(result[:openstack_auth_url]).to eq("https://keystone.example.com:5000/v3")
+ end
+ end
+
+ context "when clouds_yaml_path config is set" do
+ let(:config) { { openstack_cloud: "mycloud", clouds_yaml_path: "/my/clouds.yaml" } }
+
+ before do
+ allow(File).to receive(:exist?).and_return(false)
+ allow(File).to receive(:exist?).with("/my/clouds.yaml").and_return(true)
+ allow(File).to receive(:read)
+ .with("/my/clouds.yaml")
+ .and_return(YAML.dump(clouds_yaml_content))
+ end
+
+ it "uses the configured path" do
+ result = driver.send(:load_clouds_config)
+ expect(result[:openstack_auth_url]).to eq("https://keystone.example.com:5000/v3")
+ end
+ end
+
+ context "when cloud entry does not exist in clouds.yaml" do
+ let(:config) { { openstack_cloud: "nonexistent" } }
+
+ before do
+ allow(File).to receive(:exist?).and_return(false)
+ allow(File).to receive(:exist?)
+ .with(File.join(Dir.pwd, "clouds.yaml")).and_return(true)
+ allow(File).to receive(:read)
+ .with(File.join(Dir.pwd, "clouds.yaml"))
+ .and_return(YAML.dump(clouds_yaml_content))
+ end
+
+ it "returns an empty hash" do
+ expect(driver.send(:load_clouds_config)).to eq({})
+ end
+ end
+
+ context "with application credential auth" do
+ let(:config) { { openstack_cloud: "appcred" } }
+
+ before do
+ allow(File).to receive(:exist?).and_return(false)
+ allow(File).to receive(:exist?)
+ .with(File.join(Dir.pwd, "clouds.yaml")).and_return(true)
+ allow(File).to receive(:read)
+ .with(File.join(Dir.pwd, "clouds.yaml"))
+ .and_return(YAML.dump(clouds_yaml_content))
+ end
+
+ it "maps application credential fields" do
+ result = driver.send(:load_clouds_config)
+ expect(result[:openstack_application_credential_id]).to eq("abc123")
+ expect(result[:openstack_application_credential_secret]).to eq("secret456")
+ end
+ end
+
+ context "with SSL settings" do
+ let(:config) { { openstack_cloud: "sslcloud" } }
+
+ before do
+ allow(File).to receive(:exist?).and_return(false)
+ allow(File).to receive(:exist?)
+ .with(File.join(Dir.pwd, "clouds.yaml")).and_return(true)
+ allow(File).to receive(:read)
+ .with(File.join(Dir.pwd, "clouds.yaml"))
+ .and_return(YAML.dump(clouds_yaml_content))
+ end
+
+ it "maps SSL settings" do
+ result = driver.send(:load_clouds_config)
+ expect(result[:ssl_verify_peer]).to eq(false)
+ expect(result[:ssl_ca_file]).to eq("/path/to/ca.crt")
+ end
+ end
+ end
+
+ describe "#clouds_yaml_search_paths" do
+ before do
+ allow(ENV).to receive(:[]).and_call_original
+ allow(ENV).to receive(:[]).with("OS_CLIENT_CONFIG_FILE").and_return(nil)
+ end
+
+ context "with no env var or config path" do
+ it "returns standard search paths" do
+ paths = driver.send(:clouds_yaml_search_paths, "clouds.yaml", "OS_CLIENT_CONFIG_FILE")
+ expect(paths).to include(File.join(Dir.pwd, "clouds.yaml"))
+ expect(paths).to include(File.join(Dir.home, ".config", "openstack", "clouds.yaml"))
+ expect(paths).to include("/etc/openstack/clouds.yaml")
+ end
+ end
+
+ context "with env var set" do
+ before do
+ allow(ENV).to receive(:[]).with("OS_CLIENT_CONFIG_FILE").and_return("/custom/clouds.yaml")
+ end
+
+ it "prepends the env var path" do
+ paths = driver.send(:clouds_yaml_search_paths, "clouds.yaml", "OS_CLIENT_CONFIG_FILE")
+ expect(paths.first).to eq("/custom/clouds.yaml")
+ end
+ end
+
+ context "with clouds_yaml_path config" do
+ let(:config) { { clouds_yaml_path: "/configured/clouds.yaml" } }
+
+ it "includes the configured path" do
+ paths = driver.send(:clouds_yaml_search_paths, "clouds.yaml", "OS_CLIENT_CONFIG_FILE")
+ expect(paths).to include("/configured/clouds.yaml")
+ end
+
+ it "does not include config path for secure.yaml" do
+ paths = driver.send(:clouds_yaml_search_paths, "secure.yaml", "OS_CLIENT_SECURE_FILE")
+ expect(paths).not_to include("/configured/clouds.yaml")
+ end
+ end
+ end
+
+ describe "#translate_cloud_config" do
+ it "maps all auth keys correctly" do
+ cloud = {
+ "auth" => {
+ "auth_url" => "http://example.com:5000/v3",
+ "username" => "user",
+ "password" => "pass",
+ "project_name" => "proj",
+ "project_id" => "proj-id",
+ "user_domain_name" => "UDN",
+ "user_domain_id" => "udi",
+ "project_domain_name" => "PDN",
+ "project_domain_id" => "pdi",
+ "domain_id" => "did",
+ "domain_name" => "dname",
+ "application_credential_id" => "acid",
+ "application_credential_secret" => "acs",
+ },
+ "region_name" => "Region1",
+ "interface" => "internal",
+ "identity_api_version" => "3",
+ "verify" => true,
+ "cacert" => "/ca.pem",
+ }
+
+ result = driver.send(:translate_cloud_config, cloud)
+ expect(result[:openstack_auth_url]).to eq("http://example.com:5000/v3")
+ expect(result[:openstack_username]).to eq("user")
+ expect(result[:openstack_api_key]).to eq("pass")
+ expect(result[:openstack_project_name]).to eq("proj")
+ expect(result[:openstack_project_id]).to eq("proj-id")
+ expect(result[:openstack_user_domain]).to eq("UDN")
+ expect(result[:openstack_user_domain_id]).to eq("udi")
+ expect(result[:openstack_project_domain]).to eq("PDN")
+ expect(result[:openstack_project_domain_id]).to eq("pdi")
+ expect(result[:openstack_domain_id]).to eq("did")
+ expect(result[:openstack_domain_name]).to eq("dname")
+ expect(result[:openstack_application_credential_id]).to eq("acid")
+ expect(result[:openstack_application_credential_secret]).to eq("acs")
+ expect(result[:openstack_region]).to eq("Region1")
+ expect(result[:openstack_endpoint_type]).to eq("internal")
+ expect(result[:openstack_identity_api_version]).to eq("3")
+ expect(result[:ssl_verify_peer]).to eq(true)
+ expect(result[:ssl_ca_file]).to eq("/ca.pem")
+ end
+
+ it "handles empty auth section" do
+ result = driver.send(:translate_cloud_config, {})
+ expect(result).to eq({})
+ end
+ end
+
+ describe "#deep_merge" do
+ it "deep merges nested hashes" do
+ base = { "auth" => { "username" => "user", "password" => "base_pass" }, "region" => "r1" }
+ override = { "auth" => { "password" => "override_pass" } }
+ result = driver.send(:deep_merge, base, override)
+ expect(result["auth"]["username"]).to eq("user")
+ expect(result["auth"]["password"]).to eq("override_pass")
+ expect(result["region"]).to eq("r1")
+ end
+
+ it "does not mutate the original hashes" do
+ base = { "auth" => { "password" => "old" } }
+ override = { "auth" => { "password" => "new" } }
+ driver.send(:deep_merge, base, override)
+ expect(base["auth"]["password"]).to eq("old")
+ end
+ end
+
+ describe "#load_env_vars" do
+ before do
+ allow(ENV).to receive(:[]).and_call_original
+ Kitchen::Driver::Openstack::Clouds::ENV_VAR_MAP.each_key do |var|
+ allow(ENV).to receive(:[]).with(var).and_return(nil)
+ end
+ end
+
+ context "when no OS_* env vars are set" do
+ it "returns an empty hash" do
+ expect(driver.send(:load_env_vars)).to eq({})
+ end
+ end
+
+ context "when OS_AUTH_URL and OS_USERNAME are set" do
+ before do
+ allow(ENV).to receive(:[]).with("OS_AUTH_URL").and_return("https://env.example.com:5000/v3")
+ allow(ENV).to receive(:[]).with("OS_USERNAME").and_return("envuser")
+ end
+
+ it "maps them to fog config keys" do
+ result = driver.send(:load_env_vars)
+ expect(result[:openstack_auth_url]).to eq("https://env.example.com:5000/v3")
+ expect(result[:openstack_username]).to eq("envuser")
+ end
+ end
+
+ context "when all standard OS_* env vars are set" do
+ before do
+ allow(ENV).to receive(:[]).with("OS_AUTH_URL").and_return("https://env.example.com:5000/v3")
+ allow(ENV).to receive(:[]).with("OS_USERNAME").and_return("envuser")
+ allow(ENV).to receive(:[]).with("OS_PASSWORD").and_return("envpass")
+ allow(ENV).to receive(:[]).with("OS_PROJECT_NAME").and_return("envproject")
+ allow(ENV).to receive(:[]).with("OS_USER_DOMAIN_NAME").and_return("EnvDomain")
+ allow(ENV).to receive(:[]).with("OS_PROJECT_DOMAIN_NAME").and_return("EnvProjDomain")
+ allow(ENV).to receive(:[]).with("OS_DOMAIN_ID").and_return("envdomid")
+ allow(ENV).to receive(:[]).with("OS_REGION_NAME").and_return("EnvRegion")
+ allow(ENV).to receive(:[]).with("OS_IDENTITY_API_VERSION").and_return("3")
+ end
+
+ it "maps all env vars to fog config keys" do
+ result = driver.send(:load_env_vars)
+ expect(result[:openstack_auth_url]).to eq("https://env.example.com:5000/v3")
+ expect(result[:openstack_username]).to eq("envuser")
+ expect(result[:openstack_api_key]).to eq("envpass")
+ expect(result[:openstack_project_name]).to eq("envproject")
+ expect(result[:openstack_user_domain]).to eq("EnvDomain")
+ expect(result[:openstack_project_domain]).to eq("EnvProjDomain")
+ expect(result[:openstack_domain_id]).to eq("envdomid")
+ expect(result[:openstack_region]).to eq("EnvRegion")
+ expect(result[:openstack_identity_api_version]).to eq("3")
+ end
+ end
+
+ context "when OS_CACERT is set" do
+ before do
+ allow(ENV).to receive(:[]).with("OS_CACERT").and_return("/env/ca.crt")
+ end
+
+ it "maps to ssl_ca_file" do
+ result = driver.send(:load_env_vars)
+ expect(result[:ssl_ca_file]).to eq("/env/ca.crt")
+ end
+ end
+
+ context "when an OS_* var is empty string" do
+ before do
+ allow(ENV).to receive(:[]).with("OS_AUTH_URL").and_return("")
+ end
+
+ it "ignores the empty value" do
+ result = driver.send(:load_env_vars)
+ expect(result).not_to have_key(:openstack_auth_url)
+ end
+ end
+
+ context "with application credential env vars" do
+ before do
+ allow(ENV).to receive(:[]).with("OS_APPLICATION_CREDENTIAL_ID").and_return("appcred-id")
+ allow(ENV).to receive(:[]).with("OS_APPLICATION_CREDENTIAL_SECRET").and_return("appcred-secret")
+ end
+
+ it "maps application credential env vars" do
+ result = driver.send(:load_env_vars)
+ expect(result[:openstack_application_credential_id]).to eq("appcred-id")
+ expect(result[:openstack_application_credential_secret]).to eq("appcred-secret")
+ end
+ end
+ end
+
+ describe "#apply_clouds_config" do
+ before do
+ allow(ENV).to receive(:[]).and_call_original
+ allow(ENV).to receive(:[]).with("OS_CLIENT_CONFIG_FILE").and_return(nil)
+ allow(ENV).to receive(:[]).with("OS_CLIENT_SECURE_FILE").and_return(nil)
+ allow(ENV).to receive(:[]).with("OS_CLOUD").and_return(nil)
+ Kitchen::Driver::Openstack::Clouds::ENV_VAR_MAP.each_key do |var|
+ allow(ENV).to receive(:[]).with(var).and_return(nil)
+ end
+ end
+
+ context "when clouds.yaml provides settings" do
+ let(:config) { { openstack_cloud: "mycloud" } }
+
+ before do
+ allow(File).to receive(:exist?).and_return(false)
+ allow(File).to receive(:exist?)
+ .with(File.join(Dir.pwd, "clouds.yaml")).and_return(true)
+ allow(File).to receive(:read)
+ .with(File.join(Dir.pwd, "clouds.yaml"))
+ .and_return(YAML.dump(clouds_yaml_content))
+ end
+
+ it "merges clouds.yaml values into config" do
+ driver.send(:apply_clouds_config)
+ expect(driver[:openstack_auth_url]).to eq("https://keystone.example.com:5000/v3")
+ expect(driver[:openstack_username]).to eq("testuser")
+ expect(driver[:openstack_api_key]).to eq("testpass")
+ expect(driver[:openstack_domain_id]).to eq("default")
+ expect(driver[:openstack_region]).to eq("RegionOne")
+ end
+
+ it "does not override existing config values" do
+ config[:openstack_region] = "OverriddenRegion"
+ driver.send(:apply_clouds_config)
+ expect(driver[:openstack_region]).to eq("OverriddenRegion")
+ expect(driver[:openstack_auth_url]).to eq("https://keystone.example.com:5000/v3")
+ end
+ end
+
+ context "when SSL verify is false in clouds.yaml" do
+ let(:config) { { openstack_cloud: "sslcloud" } }
+
+ before do
+ allow(File).to receive(:exist?).and_return(false)
+ allow(File).to receive(:exist?)
+ .with(File.join(Dir.pwd, "clouds.yaml")).and_return(true)
+ allow(File).to receive(:read)
+ .with(File.join(Dir.pwd, "clouds.yaml"))
+ .and_return(YAML.dump(clouds_yaml_content))
+ end
+
+ it "sets disable_ssl_validation in config" do
+ driver.send(:apply_clouds_config)
+ expect(driver[:disable_ssl_validation]).to eq(true)
+ end
+ end
+
+ context "when no cloud is configured" do
+ it "does not modify config" do
+ original = driver[:openstack_username]
+ driver.send(:apply_clouds_config)
+ expect(driver[:openstack_username]).to eq(original)
+ end
+ end
+
+ context "using OS_CLOUD env var" do
+ before do
+ allow(ENV).to receive(:[]).with("OS_CLOUD").and_return("mycloud")
+ allow(File).to receive(:exist?).and_return(false)
+ allow(File).to receive(:exist?)
+ .with(File.join(Dir.pwd, "clouds.yaml")).and_return(true)
+ allow(File).to receive(:read)
+ .with(File.join(Dir.pwd, "clouds.yaml"))
+ .and_return(YAML.dump(clouds_yaml_content))
+ end
+
+ it "merges values from clouds.yaml via env var" do
+ driver.send(:apply_clouds_config)
+ expect(driver[:openstack_auth_url]).to eq("https://keystone.example.com:5000/v3")
+ expect(driver[:openstack_username]).to eq("testuser")
+ end
+ end
+
+ context "when only OS_* env vars are set (no clouds.yaml)" do
+ before do
+ allow(ENV).to receive(:[]).with("OS_AUTH_URL").and_return("https://env.example.com:5000/v3")
+ allow(ENV).to receive(:[]).with("OS_USERNAME").and_return("envuser")
+ allow(ENV).to receive(:[]).with("OS_PASSWORD").and_return("envpass")
+ allow(ENV).to receive(:[]).with("OS_DOMAIN_ID").and_return("envdomid")
+ allow(ENV).to receive(:[]).with("OS_REGION_NAME").and_return("EnvRegion")
+ end
+
+ it "populates config from env vars" do
+ driver.send(:apply_clouds_config)
+ expect(driver[:openstack_auth_url]).to eq("https://env.example.com:5000/v3")
+ expect(driver[:openstack_username]).to eq("envuser")
+ expect(driver[:openstack_api_key]).to eq("envpass")
+ expect(driver[:openstack_domain_id]).to eq("envdomid")
+ expect(driver[:openstack_region]).to eq("EnvRegion")
+ end
+ end
+
+ context "when OS_* env vars override clouds.yaml values" do
+ let(:config) { { openstack_cloud: "mycloud" } }
+
+ before do
+ allow(File).to receive(:exist?).and_return(false)
+ allow(File).to receive(:exist?)
+ .with(File.join(Dir.pwd, "clouds.yaml")).and_return(true)
+ allow(File).to receive(:read)
+ .with(File.join(Dir.pwd, "clouds.yaml"))
+ .and_return(YAML.dump(clouds_yaml_content))
+ allow(ENV).to receive(:[]).with("OS_REGION_NAME").and_return("EnvRegionOverride")
+ end
+
+ it "uses env var value over clouds.yaml" do
+ driver.send(:apply_clouds_config)
+ expect(driver[:openstack_region]).to eq("EnvRegionOverride")
+ # clouds.yaml values still fill remaining keys
+ expect(driver[:openstack_auth_url]).to eq("https://keystone.example.com:5000/v3")
+ expect(driver[:openstack_username]).to eq("testuser")
+ end
+ end
+
+ context "when kitchen.yml overrides OS_* env vars" do
+ let(:config) { { openstack_region: "KitchenRegion" } }
+
+ before do
+ allow(ENV).to receive(:[]).with("OS_AUTH_URL").and_return("https://env.example.com:5000/v3")
+ allow(ENV).to receive(:[]).with("OS_USERNAME").and_return("envuser")
+ allow(ENV).to receive(:[]).with("OS_PASSWORD").and_return("envpass")
+ allow(ENV).to receive(:[]).with("OS_DOMAIN_ID").and_return("envdomid")
+ allow(ENV).to receive(:[]).with("OS_REGION_NAME").and_return("EnvRegion")
+ end
+
+ it "uses kitchen.yml value over env var" do
+ driver.send(:apply_clouds_config)
+ expect(driver[:openstack_region]).to eq("KitchenRegion")
+ # env var values still fill remaining keys
+ expect(driver[:openstack_auth_url]).to eq("https://env.example.com:5000/v3")
+ expect(driver[:openstack_username]).to eq("envuser")
+ end
+ end
+
+ context "full precedence: kitchen.yml > OS_* > clouds.yaml" do
+ let(:config) { { openstack_cloud: "mycloud", openstack_username: "kitchenuser" } }
+
+ before do
+ allow(File).to receive(:exist?).and_return(false)
+ allow(File).to receive(:exist?)
+ .with(File.join(Dir.pwd, "clouds.yaml")).and_return(true)
+ allow(File).to receive(:read)
+ .with(File.join(Dir.pwd, "clouds.yaml"))
+ .and_return(YAML.dump(clouds_yaml_content))
+ allow(ENV).to receive(:[]).with("OS_USERNAME").and_return("envuser")
+ allow(ENV).to receive(:[]).with("OS_REGION_NAME").and_return("EnvRegion")
+ end
+
+ it "respects the full precedence chain" do
+ driver.send(:apply_clouds_config)
+ # kitchen.yml wins over both env and clouds.yaml
+ expect(driver[:openstack_username]).to eq("kitchenuser")
+ # OS_* env var wins over clouds.yaml
+ expect(driver[:openstack_region]).to eq("EnvRegion")
+ # clouds.yaml fills remaining nils
+ expect(driver[:openstack_auth_url]).to eq("https://keystone.example.com:5000/v3")
+ expect(driver[:openstack_api_key]).to eq("testpass")
+ end
+ end
+ end
+
+ describe "#openstack_server with clouds.yaml" do
+ let(:config) { { openstack_cloud: "mycloud" } }
+
+ before do
+ allow(ENV).to receive(:[]).and_call_original
+ allow(ENV).to receive(:[]).with("OS_CLIENT_CONFIG_FILE").and_return(nil)
+ allow(ENV).to receive(:[]).with("OS_CLIENT_SECURE_FILE").and_return(nil)
+ allow(ENV).to receive(:[]).with("OS_CLOUD").and_return(nil)
+ Kitchen::Driver::Openstack::Clouds::ENV_VAR_MAP.each_key do |var|
+ allow(ENV).to receive(:[]).with(var).and_return(nil)
+ end
+ allow(File).to receive(:exist?).and_return(false)
+ allow(File).to receive(:exist?)
+ .with(File.join(Dir.pwd, "clouds.yaml")).and_return(true)
+ allow(File).to receive(:read)
+ .with(File.join(Dir.pwd, "clouds.yaml"))
+ .and_return(YAML.dump(clouds_yaml_content))
+ end
+
+ it "populates server settings after apply_clouds_config" do
+ driver.send(:apply_clouds_config)
+ result = driver.send(:openstack_server)
+ expect(result[:openstack_auth_url]).to eq("https://keystone.example.com:5000/v3")
+ expect(result[:openstack_username]).to eq("testuser")
+ expect(result[:openstack_api_key]).to eq("testpass")
+ expect(result[:openstack_domain_id]).to eq("default")
+ expect(result[:openstack_region]).to eq("RegionOne")
+ end
+
+ context "when kitchen.yml overrides clouds.yaml values" do
+ let(:config) do
+ {
+ openstack_cloud: "mycloud",
+ openstack_region: "OverriddenRegion",
+ }
+ end
+
+ it "uses the kitchen.yml value for the overridden key" do
+ driver.send(:apply_clouds_config)
+ result = driver.send(:openstack_server)
+ expect(result[:openstack_region]).to eq("OverriddenRegion")
+ expect(result[:openstack_auth_url]).to eq("https://keystone.example.com:5000/v3")
+ end
+ end
+
+ context "using OS_CLOUD env var" do
+ let(:config) { {} }
+
+ before do
+ allow(ENV).to receive(:[]).with("OS_CLOUD").and_return("mycloud")
+ end
+
+ it "populates server settings from clouds.yaml via env var" do
+ driver.send(:apply_clouds_config)
+ result = driver.send(:openstack_server)
+ expect(result[:openstack_auth_url]).to eq("https://keystone.example.com:5000/v3")
+ expect(result[:openstack_username]).to eq("testuser")
+ end
+ end
+
+ context "using only OS_* env vars (no clouds.yaml)" do
+ let(:config) { {} }
+
+ before do
+ allow(File).to receive(:exist?).and_return(false)
+ allow(ENV).to receive(:[]).with("OS_AUTH_URL").and_return("https://env.example.com:5000/v3")
+ allow(ENV).to receive(:[]).with("OS_USERNAME").and_return("envuser")
+ allow(ENV).to receive(:[]).with("OS_PASSWORD").and_return("envpass")
+ allow(ENV).to receive(:[]).with("OS_DOMAIN_ID").and_return("envdomid")
+ end
+
+ it "populates server settings from env vars" do
+ driver.send(:apply_clouds_config)
+ result = driver.send(:openstack_server)
+ expect(result[:openstack_auth_url]).to eq("https://env.example.com:5000/v3")
+ expect(result[:openstack_username]).to eq("envuser")
+ expect(result[:openstack_api_key]).to eq("envpass")
+ expect(result[:openstack_domain_id]).to eq("envdomid")
+ end
+ end
+ end
+end
diff --git a/spec/kitchen/driver/openstack_spec.rb b/spec/kitchen/driver/openstack_spec.rb
index b47e674..d7117cc 100755
--- a/spec/kitchen/driver/openstack_spec.rb
+++ b/spec/kitchen/driver/openstack_spec.rb
@@ -71,6 +71,8 @@
nils = %i{
server_name
+ openstack_cloud
+ clouds_yaml_path
openstack_project_name
openstack_region
openstack_service_name