From fec11972acc92d25e4c61aab12d2c998d6decb23 Mon Sep 17 00:00:00 2001 From: Lance Albertson Date: Wed, 22 Apr 2026 09:51:50 -0700 Subject: [PATCH] fix(driver): coerce OpenStack auth/config values to strings before Fog When clouds.yaml or .kitchen.yml provides scalar values such as `identity_api_version: 3` (Integer) or numeric IDs, fog-openstack crashes during authentication because `Fog::OpenStack::Auth::Token.build` calls `=~` on the value: NoMethodError: undefined method '=~' for an instance of Integer Two layers of coercion are applied: 1. `Clouds` ingestion (clouds.rb): values mapped from clouds.yaml, secure.yaml, and `OS_*` env vars are normalized to strings via `STRING_CONFIG_KEYS`/`normalize_config_value` before being merged into `config`. 2. Fog handoff (openstack.rb): `openstack_server` wraps every value with `normalize_fog_setting`, which converts known string-typed Fog auth/config keys (listed in `FOG_STRING_SETTINGS`) to strings while preserving `nil` so that downstream "missing required argument" validation still fires. Additionally, `openstack_identity_api_version` requires special handling. Fog::Service#coerce_options re-coerces any value where `value.to_s.to_i.to_s == value.to_s` back to an Integer, undoing the String coercion before Token.build sees it. The value is now prefixed with "v" (e.g. `3` -> `"v3"`, `2`/`2.0` -> `"v2.0"`), which prevents Fog's coercion round-trip while still satisfying Token.build's `/(v)*2(\.0)*/i` regex check for v2 detection. Regression specs added: - spec/kitchen/driver/openstack/clouds_spec.rb verifies that `translate_cloud_config` coerces non-string scalars for known string keys. - spec/kitchen/driver/openstack_spec.rb verifies that `openstack_server` coerces numeric values for fog string settings and that `identity_api_version` is v-prefixed so it survives Fog::Service#coerce_options. Signed-off-by: Lance Albertson --- lib/kitchen/driver/openstack.rb | 49 +++++++++++++++++++- lib/kitchen/driver/openstack/clouds.rb | 18 +++++-- spec/kitchen/driver/openstack/clouds_spec.rb | 15 ++++++ spec/kitchen/driver/openstack_spec.rb | 38 +++++++++++++++ 4 files changed, 115 insertions(+), 5 deletions(-) diff --git a/lib/kitchen/driver/openstack.rb b/lib/kitchen/driver/openstack.rb index e7c9b7d..e0003f0 100755 --- a/lib/kitchen/driver/openstack.rb +++ b/lib/kitchen/driver/openstack.rb @@ -36,6 +36,27 @@ module Kitchen module Driver # This takes from the Base Class and creates the OpenStack driver. class Openstack < Kitchen::Driver::Base + FOG_STRING_SETTINGS = %i{ + openstack_username + openstack_api_key + openstack_auth_url + openstack_project_name + openstack_project_id + openstack_user_domain + openstack_user_domain_id + openstack_project_domain + openstack_project_domain_id + openstack_domain_id + openstack_domain_name + openstack_region + openstack_endpoint_type + openstack_identity_api_version + openstack_application_credential_id + openstack_application_credential_secret + openstack_tenant + openstack_tenant_id + }.freeze + include Clouds include Config include Helpers @@ -149,8 +170,8 @@ def openstack_server server_def = { connection_options: {}, } - required_server_settings.each { |s| server_def[s] = config[s] } - optional_server_settings.each { |s| server_def[s] = config[s] if config[s] } + required_server_settings.each { |s| server_def[s] = normalize_fog_setting(s, config[s]) } + optional_server_settings.each { |s| server_def[s] = normalize_fog_setting(s, config[s]) if config[s] } connection_options.each { |s| server_def[:connection_options][s] = config[s] if config[s] } server_def end @@ -184,6 +205,30 @@ def volume def get_bdm(config) volume.get_bdm(config, openstack_server) end + + def normalize_fog_setting(setting, value) + return value if value.nil? + return normalize_identity_api_version(value) if setting == :openstack_identity_api_version + return value unless FOG_STRING_SETTINGS.include?(setting) + + value.to_s + end + + # Fog::Service#coerce_options re-coerces any value where + # `value.to_s.to_i.to_s == value.to_s` back to an Integer, which + # then breaks Fog::OpenStack::Auth::Token.build (it calls `=~` + # on the value). Prefixing with "v" keeps Fog from coercing and + # still satisfies Token.build's `/(v)*2(\.0)*/i` regex check. + def normalize_identity_api_version(value) + str = value.to_s.strip + return str if str.empty? + return str if str.start_with?("v", "V") + + case str + when "2", "2.0" then "v2.0" + else "v#{str}" + end + end end end end diff --git a/lib/kitchen/driver/openstack/clouds.rb b/lib/kitchen/driver/openstack/clouds.rb index 92d2c4b..9be1081 100644 --- a/lib/kitchen/driver/openstack/clouds.rb +++ b/lib/kitchen/driver/openstack/clouds.rb @@ -48,6 +48,10 @@ module Clouds "identity_api_version" => :openstack_identity_api_version, }.freeze + # Fog expects these config values to be strings. YAML may parse + # unquoted scalars as integers/booleans, so normalize on ingest. + STRING_CONFIG_KEYS = (CLOUDS_YAML_AUTH_MAP.values + CLOUDS_YAML_TOP_MAP.values).freeze + # Mapping of OS_* environment variables to Fog OpenStack config keys ENV_VAR_MAP = { "OS_AUTH_URL" => :openstack_auth_url, @@ -100,7 +104,7 @@ 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? + result[fog_key] = normalize_config_value(fog_key, value) if value && !value.empty? end result end @@ -175,12 +179,14 @@ def translate_cloud_config(cloud) # 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] + value = auth[yaml_key] + result[fog_key] = normalize_config_value(fog_key, value) if value 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] + value = cloud[yaml_key] + result[fog_key] = normalize_config_value(fog_key, value) if value end # SSL settings @@ -189,6 +195,12 @@ def translate_cloud_config(cloud) result end + + def normalize_config_value(fog_key, value) + return value unless STRING_CONFIG_KEYS.include?(fog_key) + + value.to_s + end end end end diff --git a/spec/kitchen/driver/openstack/clouds_spec.rb b/spec/kitchen/driver/openstack/clouds_spec.rb index 1bae21c..c465322 100644 --- a/spec/kitchen/driver/openstack/clouds_spec.rb +++ b/spec/kitchen/driver/openstack/clouds_spec.rb @@ -384,6 +384,21 @@ result = driver.send(:translate_cloud_config, {}) expect(result).to eq({}) end + + it "coerces non-string scalar values for fog string config keys" do + cloud = { + "auth" => { + "project_id" => 12_345, + "domain_id" => 9, + }, + "identity_api_version" => 3, + } + + result = driver.send(:translate_cloud_config, cloud) + expect(result[:openstack_project_id]).to eq("12345") + expect(result[:openstack_domain_id]).to eq("9") + expect(result[:openstack_identity_api_version]).to eq("3") + end end describe "#deep_merge" do diff --git a/spec/kitchen/driver/openstack_spec.rb b/spec/kitchen/driver/openstack_spec.rb index d7117cc..1fa150b 100755 --- a/spec/kitchen/driver/openstack_spec.rb +++ b/spec/kitchen/driver/openstack_spec.rb @@ -346,6 +346,44 @@ expected = config.merge(config) expect(driver.send(:openstack_server)).to eq(expected) end + + context "when string-like fog settings are numeric" do + let(:config) do + { + openstack_username: "a", + openstack_domain_id: 12_345, + openstack_api_key: "b", + openstack_auth_url: "http://", + openstack_project_id: 99, + openstack_identity_api_version: 3, + } + end + + it "coerces them to strings before passing to fog" do + server = driver.send(:openstack_server) + + expect(server[:openstack_domain_id]).to eq("12345") + expect(server[:openstack_project_id]).to eq("99") + # identity_api_version is prefixed with "v" so that + # Fog::Service#coerce_options does not turn it back into an + # Integer (which would later break Token.build's `=~`). + expect(server[:openstack_identity_api_version]).to eq("v3") + end + + it "prefixes identity_api_version 2 with a v for fog v2 detection" do + config[:openstack_identity_api_version] = 2 + server = driver.send(:openstack_server) + + expect(server[:openstack_identity_api_version]).to eq("v2.0") + end + + it "passes through identity_api_version values already prefixed" do + config[:openstack_identity_api_version] = "v3" + server = driver.send(:openstack_server) + + expect(server[:openstack_identity_api_version]).to eq("v3") + end + end end describe "#required_server_settings" do