From 470abed2bccab9c3979b64d28d9ad7e95d220b1d Mon Sep 17 00:00:00 2001 From: Lance Albertson Date: Thu, 2 Apr 2026 16:49:04 -0700 Subject: [PATCH 1/4] refactor: split main driver file into focused modules Resolves #24 - the main openstack.rb driver file had grown too large with too many concerns in a single class. Extract four new modules from Kitchen::Driver::Openstack: - Config (openstack/config.rb): server naming logic including config_server_name, default_name, and server_name_prefix - Networking (openstack/networking.rb): floating IP allocation and IP address resolution including attach_ip, attach_ip_from_pool, get_ip, get_public_private_ips, filter_ips, and parse_ips - ServerHelper (openstack/server_helper.rb): server creation and resource finders including create_server, init_configuration, optional_config, find_image, find_flavor, find_network, and find_matching - Helpers (openstack/helpers.rb): ohai hints, SSL validation, and server wait logic including add_ohai_hint, disable_ssl_validation, wait_for_server, and countdown The main openstack.rb retains the class definition, create/destroy lifecycle methods, Fog connection helpers, and default_config declarations. Additional changes: - Fix Style/ClassVars offenses by replacing class variables with constants (IP_POOL_LOCK in Networking, DEFAULT_CREATION_TIMEOUT in Volume) - Fix Style/FileRead by using File.read and updating the corresponding spec mock - Remove obsolete --chefstyle flag from Rakefile (no longer supported by newer RuboCop; .rubocop.yml already loads cookstyle via require) - Auto-correct all cookstyle offenses (string quoting, comment format, percent literal delimiters) - Add Lance Albertson as author and Oregon State University copyright Signed-off-by: Lance Albertson --- Gemfile | 14 +- Rakefile | 26 +- kitchen-openstack.gemspec | 28 +- lib/kitchen/driver/openstack.rb | 336 ++---------------- lib/kitchen/driver/openstack/config.rb | 87 +++++ lib/kitchen/driver/openstack/helpers.rb | 83 +++++ lib/kitchen/driver/openstack/networking.rb | 129 +++++++ lib/kitchen/driver/openstack/server_helper.rb | 137 +++++++ lib/kitchen/driver/openstack/volume.rb | 24 +- lib/kitchen/driver/openstack_version.rb | 6 +- spec/kitchen/driver/openstack_spec.rb | 2 +- 11 files changed, 509 insertions(+), 363 deletions(-) create mode 100644 lib/kitchen/driver/openstack/config.rb create mode 100644 lib/kitchen/driver/openstack/helpers.rb create mode 100644 lib/kitchen/driver/openstack/networking.rb create mode 100644 lib/kitchen/driver/openstack/server_helper.rb diff --git a/Gemfile b/Gemfile index c441101..52cf503 100644 --- a/Gemfile +++ b/Gemfile @@ -1,21 +1,21 @@ # frozen_string_literal: true -source "https://rubygems.org" +source 'https://rubygems.org' # Specify your gem's dependencies in kitchen-openstack.gemspec gemspec group :test do - gem "rake" - gem "kitchen-inspec" - gem "rspec", "~> 3.2" - gem "countloc" + gem 'rake' + gem 'kitchen-inspec' + gem 'rspec', '~> 3.2' + gem 'countloc' end group :debug do - gem "pry" + gem 'pry' end group :cookstyle do - gem "cookstyle" + gem 'cookstyle' end diff --git a/Rakefile b/Rakefile index d75e210..eec8159 100644 --- a/Rakefile +++ b/Rakefile @@ -1,30 +1,30 @@ -require "bundler/gem_tasks" -require "rspec/core/rake_task" +require 'bundler/gem_tasks' +require 'rspec/core/rake_task' RSpec::Core::RakeTask.new(:unit) -desc "Run all test suites" +desc 'Run all test suites' task test: [:unit] -desc "Display LOC stats" +desc 'Display LOC stats' task :stats do puts "\n## Production Code Stats" - sh "countloc -r lib" + sh 'countloc -r lib' puts "\n## Test Code Stats" - sh "countloc -r spec" + sh 'countloc -r spec' end begin - require "cookstyle" - require "rubocop/rake_task" + require 'cookstyle' + require 'rubocop/rake_task' RuboCop::RakeTask.new(:style) do |task| - task.options += ["--chefstyle", "--display-cop-names", "--no-color"] + task.options += ['--display-cop-names', '--no-color'] end rescue LoadError - puts "cookstyle is not available. gem install cookstyle to do style checking." + puts 'cookstyle is not available. gem install cookstyle to do style checking.' end -desc "Run all quality tasks" -task quality: %i{style stats} +desc 'Run all quality tasks' +task quality: %i(style stats) -task default: %i{test quality} +task default: %i(test quality) diff --git a/kitchen-openstack.gemspec b/kitchen-openstack.gemspec index 7b7d6df..9c402b3 100644 --- a/kitchen-openstack.gemspec +++ b/kitchen-openstack.gemspec @@ -1,25 +1,25 @@ # frozen_string_literal: true -lib = File.expand_path("lib", __dir__) +lib = File.expand_path('lib', __dir__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) -require "kitchen/driver/openstack_version" +require 'kitchen/driver/openstack_version' Gem::Specification.new do |spec| - spec.name = "kitchen-openstack" + spec.name = 'kitchen-openstack' spec.version = Kitchen::Driver::OPENSTACK_VERSION - spec.authors = ["Jonathan Hartman", "JJ Asghar"] - spec.email = ["j@p4nt5.com", "jj@chef.io"] - spec.description = "A Test Kitchen OpenStack Nova driver" + spec.authors = ['Jonathan Hartman', 'JJ Asghar'] + spec.email = ['j@p4nt5.com', 'jj@chef.io'] + spec.description = 'A Test Kitchen OpenStack Nova driver' spec.summary = spec.description - spec.homepage = "https://github.com/test-kitchen/kitchen-openstack" - spec.license = "Apache-2.0" + spec.homepage = 'https://github.com/test-kitchen/kitchen-openstack' + spec.license = 'Apache-2.0' - spec.files = Dir["LICENSE", "README.md", "lib/**/*"] - spec.require_paths = ["lib"] + spec.files = Dir['LICENSE', 'README.md', 'lib/**/*'] + spec.require_paths = ['lib'] - spec.required_ruby_version = ">= 3.1" + spec.required_ruby_version = '>= 3.1' - spec.add_dependency "test-kitchen", ">= 1.4.1", "< 5" - spec.add_dependency "fog-openstack", "~> 1.0" - spec.add_dependency "ohai" + spec.add_dependency 'test-kitchen', '>= 1.4.1', '< 5' + spec.add_dependency 'fog-openstack', '~> 1.0' + spec.add_dependency 'ohai' end diff --git a/lib/kitchen/driver/openstack.rb b/lib/kitchen/driver/openstack.rb index ac53857..b78cbe8 100755 --- a/lib/kitchen/driver/openstack.rb +++ b/lib/kitchen/driver/openstack.rb @@ -3,9 +3,11 @@ # # Author:: Jonathan Hartman () # Author:: JJ Asghar () +# Author:: Lance Albertson () # -# Copyright (C) 2013-2015, Jonathan Hartman -# Copyright (C) 2015-2020, Chef Software Inc. +# Copyright:: (C) 2013-2015, Jonathan Hartman +# Copyright:: (C) 2015-2020, Chef Software Inc. +# 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. @@ -19,18 +21,24 @@ # See the License for the specific language governing permissions and # limitations under the License. -require "kitchen" -require "fog/openstack" -require "ohai" unless defined?(Ohai::System) -require "yaml" -require_relative "openstack_version" -require_relative "openstack/volume" +require 'kitchen' +require 'fog/openstack' +require 'yaml' +require_relative 'openstack_version' +require_relative 'openstack/config' +require_relative 'openstack/helpers' +require_relative 'openstack/networking' +require_relative 'openstack/server_helper' +require_relative 'openstack/volume' module Kitchen module Driver # This takes from the Base Class and creates the OpenStack driver. class Openstack < Kitchen::Driver::Base - @@ip_pool_lock = Mutex.new + include Config + include Helpers + include Networking + include ServerHelper kitchen_driver_api_version 2 plugin_version Kitchen::Driver::OPENSTACK_VERSION @@ -38,7 +46,7 @@ class Openstack < Kitchen::Driver::Base default_config :server_name, nil default_config :server_name_prefix, nil default_config :key_name, nil - default_config :port, "22" + default_config :port, '22' default_config :use_ipv6, false default_config :openstack_project_name, nil default_config :openstack_region, nil @@ -62,17 +70,6 @@ class Openstack < Kitchen::Driver::Base default_config :write_timeout, 60 default_config :metadata, nil - # Set the proper server name in the config - def config_server_name - return if config[:server_name] - - config[:server_name] = if config[:server_name_prefix] - server_name_prefix(config[:server_name_prefix]) - else - default_name - end - end - def create(state) config_server_name if state[:server_id] @@ -113,13 +110,13 @@ def destroy(state) unless server.nil? if config[:floating_ip_pool] && config[:allocate_floating_ip] - info "Retrieve the floating IP" + info 'Retrieve the floating IP' pub, priv = get_public_private_ips(server) pub, = parse_ips(pub, priv) pub_ip = pub[config[:public_ip_order].to_i] || nil if pub_ip info "Retrieve the ID of floating IP <#{pub_ip}>" - floating_ip_id = network.list_floating_ips(floating_ip_address: pub_ip).body["floatingips"][0]["id"] + floating_ip_id = network.list_floating_ips(floating_ip_address: pub_ip).body['floatingips'][0]['id'] network.delete_floating_ip(floating_ip_id) info "OpenStack Floating IP <#{pub_ip}> released." end @@ -144,17 +141,17 @@ def openstack_server end def required_server_settings - %i{openstack_username openstack_api_key openstack_auth_url openstack_domain_id} + %i(openstack_username openstack_api_key openstack_auth_url openstack_domain_id) end def optional_server_settings Fog::OpenStack::Compute.recognized.select do |k| - k.to_s.start_with?("openstack") + k.to_s.start_with?('openstack') end - required_server_settings end def connection_options - %i{read_timeout write_timeout connect_timeout} + %i(read_timeout write_timeout connect_timeout) end def network @@ -172,293 +169,6 @@ def volume def get_bdm(config) volume.get_bdm(config, openstack_server) end - - def create_server - server_def = init_configuration - raise(ActionFailed, "Cannot specify both network_ref and network_id") if config[:network_id] && config[:network_ref] - - if config[:network_id] - networks = [].push(config[:network_id]) - server_def[:nics] = networks.flatten.map do |net_id| - { "net_id" => net_id } - end - elsif config[:network_ref] - networks = [].push(config[:network_ref]) - server_def[:nics] = networks.flatten.map do |net| - { "net_id" => find_network(net).id } - end - end - - if config[:block_device_mapping] - server_def[:block_device_mapping] = get_bdm(config) - end - - %i{ - security_groups - key_name - user_data - config_drive - metadata - }.each do |c| - server_def[c] = optional_config(c) if config[c] - end - - if config[:cloud_config] - raise(ActionFailed, "Cannot specify both cloud_config and user_data") if config[:user_data] - - server_def[:user_data] = Kitchen::Util.stringified_hash(config[:cloud_config]).to_yaml.gsub(/^---\n/, "#cloud-config\n") - end - - # Can't use the Fog bootstrap and/or setup methods here; they require a - # public IP address that can't be guaranteed to exist across all - # OpenStack deployments (e.g. TryStack ARM only has private IPs). - compute.servers.create(server_def) - end - - def init_configuration - raise(ActionFailed, "Cannot specify both image_ref and image_id") if config[:image_id] && config[:image_ref] - raise(ActionFailed, "Cannot specify both flavor_ref and flavor_id") if config[:flavor_id] && config[:flavor_ref] - - { - name: config[:server_name], - image_ref: config[:image_id] || find_image(config[:image_ref]).id, - flavor_ref: config[:flavor_id] || find_flavor(config[:flavor_ref]).id, - availability_zone: config[:availability_zone], - } - end - - def optional_config(c) - case c - when :security_groups - config[c] if config[c].is_a?(Array) - when :user_data - File.open(config[c], &:read) if File.exist?(config[c]) - else - config[c] - end - end - - def find_image(image_ref) - image = find_matching(compute.images, image_ref) - raise(ActionFailed, "Image not found") unless image - - debug "Selected image: #{image.id} #{image.name}" - image - end - - def find_flavor(flavor_ref) - flavor = find_matching(compute.flavors, flavor_ref) - raise(ActionFailed, "Flavor not found") unless flavor - - debug "Selected flavor: #{flavor.id} #{flavor.name}" - flavor - end - - def find_network(network_ref) - net = find_matching(network.networks.all, network_ref) - raise(ActionFailed, "Network not found") unless net - - debug "Selected net: #{net.id} #{net.name}" - net - end - - # Generate what should be a unique server name up to 63 total chars - # Base name: 15 - # Username: 15 - # Hostname: 23 - # Random string: 7 - # Separators: 3 - # ================ - # Total: 63 - def default_name - [ - instance.name.gsub(/\W/, "")[0..14], - ((Etc.getpwuid ? Etc.getpwuid.name : Etc.getlogin) || "nologin").gsub(/\W/, "")[0..14], - Socket.gethostname.gsub(/\W/, "")[0..22], - Array.new(7) { rand(36).to_s(36) }.join, - ].join("-") - end - - def server_name_prefix(server_name_prefix) - # Generate what should be a unique server name with given prefix - # of up to 63 total chars - # - # Provided prefix: variable, max 54 - # Separator: 1 - # Random string: 8 - # =================== - # Max: 63 - # - if server_name_prefix.length > 54 - warn "Server name prefix too long, truncated to 54 characters" - server_name_prefix = server_name_prefix[0..53] - end - - server_name_prefix.gsub!(/\W/, "") - - if server_name_prefix.empty? - warn "Server name prefix empty or invalid; using fully generated name" - default_name - else - random_suffix = ("a".."z").to_a.sample(8).join - server_name_prefix + "-" + random_suffix - end - end - - def attach_ip_from_pool(server, pool) - @@ip_pool_lock.synchronize do - info "Attaching floating IP from <#{pool}> pool" - if config[:allocate_floating_ip] - network_id = network - .list_networks( - name: pool - ).body["networks"][0]["id"] - resp = network.create_floating_ip(network_id) - ip = resp.body["floatingip"]["floating_ip_address"] - info "Created floating IP <#{ip}> from <#{pool}> pool" - config[:floating_ip] = ip - else - free_addrs = compute.addresses.map do |i| - i.ip if i.fixed_ip.nil? && i.instance_id.nil? && i.pool == pool - end.compact - if free_addrs.empty? - raise ActionFailed, "No available IPs in pool <#{pool}>" - end - - config[:floating_ip] = free_addrs[0] - end - attach_ip(server, config[:floating_ip]) - end - end - - def attach_ip(server, ip) - info "Attaching floating IP <#{ip}>" - server.associate_address ip - end - - def get_public_private_ips(server) - begin - pub = server.public_ip_addresses - priv = server.private_ip_addresses - rescue Fog::OpenStack::Compute::NotFound, Excon::Errors::Forbidden - # See Fog issue: https://github.com/fog/fog/issues/2160 - addrs = server.addresses - addrs["public"] && pub = addrs["public"].map { |i| i["addr"] } - addrs["private"] && priv = addrs["private"].map { |i| i["addr"] } - end - [pub, priv] - end - - def get_ip(server) - if config[:floating_ip] - debug "Using floating ip: #{config[:floating_ip]}" - return config[:floating_ip] - end - - # make sure we have the latest info - info "Waiting for network information to be available..." - begin - w = server.wait_for { !addresses.empty? } - debug "Waited #{w[:duration]} seconds for network information." - rescue Fog::Errors::TimeoutError - raise ActionFailed, "Could not get network information (timed out)" - end - - # should also work for private networks - if config[:openstack_network_name] - debug "Using configured net: #{config[:openstack_network_name]}" - return filter_ips(server.addresses[config[:openstack_network_name]]).first["addr"] - end - - pub, priv = get_public_private_ips(server) - priv = server.ip_addresses if Array(pub).empty? && Array(priv).empty? - pub, priv = parse_ips(pub, priv) - pub[config[:public_ip_order].to_i] || - priv[config[:private_ip_order].to_i] || - raise(ActionFailed, "Could not find an IP") - end - - def filter_ips(addresses) - if config[:use_ipv6] - addresses.select { |i| IPAddr.new(i["addr"]).ipv6? } - else - addresses.select { |i| IPAddr.new(i["addr"]).ipv4? } - end - end - - def parse_ips(pub, priv) - pub = Array(pub) - priv = Array(priv) - if config[:use_ipv6] - [pub, priv].each { |n| n.select! { |i| IPAddr.new(i).ipv6? } } - else - [pub, priv].each { |n| n.select! { |i| IPAddr.new(i).ipv4? } } - end - [pub, priv] - end - - def add_ohai_hint(state) - if bourne_shell? - info "Adding OpenStack hint for ohai" - mkdir_cmd = "sudo mkdir -p #{hints_path}" - touch_cmd = "sudo bash -c 'echo {} > #{hints_path}/openstack.json'" - instance.transport.connection(state).execute( - "#{mkdir_cmd} && #{touch_cmd}" - ) - elsif windows_os? - info "Adding OpenStack hint for ohai" - touch_cmd = "New-Item #{hints_path}\\openstack.json" - touch_cmd_args = "-Value '{}' -Force -Type file" - instance.transport.connection(state).execute( - "#{touch_cmd} #{touch_cmd_args}" - ) - end - end - - def hints_path - Ohai.config[:hints_path][0] - end - - def disable_ssl_validation - require "excon" unless defined?(Excon) - Excon.defaults[:ssl_verify_peer] = false - end - - def wait_for_server(state) - if config[:server_wait] - info "Sleeping for #{config[:server_wait]} seconds to let your server start up..." - countdown(config[:server_wait]) - end - info "Waiting for server to be ready..." - instance.transport.connection(state).wait_until_ready - rescue - error "Server #{state[:hostname]} (#{state[:server_id]}) not reachable. Destroying server..." - destroy(state) - raise - end - - def countdown(seconds) - date1 = Time.now + seconds - while Time.now < date1 - Kernel.print "." - sleep 10 - end - end - - def find_matching(collection, name) - name = name.to_s - if name.start_with?("/") && name.end_with?("/") - regex = Regexp.new(name[1...-1]) - # check for regex name match - collection.each { |single| return single if regex&.match?(single.name) } - else - # check for exact id match - collection.each { |single| return single if single.id == name } - # check for exact name match - collection.each { |single| return single if single.name == name } - end - nil - end end end end diff --git a/lib/kitchen/driver/openstack/config.rb b/lib/kitchen/driver/openstack/config.rb new file mode 100644 index 0000000..bc51245 --- /dev/null +++ b/lib/kitchen/driver/openstack/config.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# +# Author:: Jonathan Hartman () +# Author:: JJ Asghar () +# Author:: Lance Albertson () +# +# Copyright:: (C) 2013-2015, Jonathan Hartman +# Copyright:: (C) 2015-2020, Chef Software Inc. +# 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. + +module Kitchen + module Driver + class Openstack < Kitchen::Driver::Base + # Server naming and configuration helpers + module Config + # Set the proper server name in the config + def config_server_name + return if config[:server_name] + + config[:server_name] = if config[:server_name_prefix] + server_name_prefix(config[:server_name_prefix]) + else + default_name + end + end + + private + + # Generate what should be a unique server name up to 63 total chars + # Base name: 15 + # Username: 15 + # Hostname: 23 + # Random string: 7 + # Separators: 3 + # ================ + # Total: 63 + def default_name + [ + instance.name.gsub(/\W/, '')[0..14], + ((Etc.getpwuid ? Etc.getpwuid.name : Etc.getlogin) || 'nologin').gsub(/\W/, '')[0..14], + Socket.gethostname.gsub(/\W/, '')[0..22], + Array.new(7) { rand(36).to_s(36) }.join, + ].join('-') + end + + def server_name_prefix(server_name_prefix) + # Generate what should be a unique server name with given prefix + # of up to 63 total chars + # + # Provided prefix: variable, max 54 + # Separator: 1 + # Random string: 8 + # =================== + # Max: 63 + # + if server_name_prefix.length > 54 + warn 'Server name prefix too long, truncated to 54 characters' + server_name_prefix = server_name_prefix[0..53] + end + + server_name_prefix.gsub!(/\W/, '') + + if server_name_prefix.empty? + warn 'Server name prefix empty or invalid; using fully generated name' + default_name + else + random_suffix = ('a'..'z').to_a.sample(8).join + server_name_prefix + '-' + random_suffix + end + end + end + end + end +end diff --git a/lib/kitchen/driver/openstack/helpers.rb b/lib/kitchen/driver/openstack/helpers.rb new file mode 100644 index 0000000..a1c3f53 --- /dev/null +++ b/lib/kitchen/driver/openstack/helpers.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +# +# Author:: Jonathan Hartman () +# Author:: JJ Asghar () +# Author:: Lance Albertson () +# +# Copyright:: (C) 2013-2015, Jonathan Hartman +# Copyright:: (C) 2015-2020, Chef Software Inc. +# 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 'ohai' unless defined?(Ohai::System) + +module Kitchen + module Driver + class Openstack < Kitchen::Driver::Base + # Ohai hints, SSL handling, and server wait helpers + module Helpers + private + + def add_ohai_hint(state) + if bourne_shell? + info 'Adding OpenStack hint for ohai' + mkdir_cmd = "sudo mkdir -p #{hints_path}" + touch_cmd = "sudo bash -c 'echo {} > #{hints_path}/openstack.json'" + instance.transport.connection(state).execute( + "#{mkdir_cmd} && #{touch_cmd}" + ) + elsif windows_os? + info 'Adding OpenStack hint for ohai' + touch_cmd = "New-Item #{hints_path}\\openstack.json" + touch_cmd_args = "-Value '{}' -Force -Type file" + instance.transport.connection(state).execute( + "#{touch_cmd} #{touch_cmd_args}" + ) + end + end + + def hints_path + Ohai.config[:hints_path][0] + end + + def disable_ssl_validation + require 'excon' unless defined?(Excon) + Excon.defaults[:ssl_verify_peer] = false + end + + def wait_for_server(state) + if config[:server_wait] + info "Sleeping for #{config[:server_wait]} seconds to let your server start up..." + countdown(config[:server_wait]) + end + info 'Waiting for server to be ready...' + instance.transport.connection(state).wait_until_ready + rescue + error "Server #{state[:hostname]} (#{state[:server_id]}) not reachable. Destroying server..." + destroy(state) + raise + end + + def countdown(seconds) + date1 = Time.now + seconds + while Time.now < date1 + Kernel.print '.' + sleep 10 + end + end + end + end + end +end diff --git a/lib/kitchen/driver/openstack/networking.rb b/lib/kitchen/driver/openstack/networking.rb new file mode 100644 index 0000000..867f142 --- /dev/null +++ b/lib/kitchen/driver/openstack/networking.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +# +# Author:: Jonathan Hartman () +# Author:: JJ Asghar () +# Author:: Lance Albertson () +# +# Copyright:: (C) 2013-2015, Jonathan Hartman +# Copyright:: (C) 2015-2020, Chef Software Inc. +# 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 'ipaddr' unless defined?(IPAddr) + +module Kitchen + module Driver + class Openstack < Kitchen::Driver::Base + # Floating IP allocation and IP address resolution + module Networking + IP_POOL_LOCK = Mutex.new + + private + + def attach_ip_from_pool(server, pool) + IP_POOL_LOCK.synchronize do + info "Attaching floating IP from <#{pool}> pool" + if config[:allocate_floating_ip] + network_id = network + .list_networks( + name: pool + ).body['networks'][0]['id'] + resp = network.create_floating_ip(network_id) + ip = resp.body['floatingip']['floating_ip_address'] + info "Created floating IP <#{ip}> from <#{pool}> pool" + config[:floating_ip] = ip + else + free_addrs = compute.addresses.map do |i| + i.ip if i.fixed_ip.nil? && i.instance_id.nil? && i.pool == pool + end.compact + if free_addrs.empty? + raise ActionFailed, "No available IPs in pool <#{pool}>" + end + + config[:floating_ip] = free_addrs[0] + end + attach_ip(server, config[:floating_ip]) + end + end + + def attach_ip(server, ip) + info "Attaching floating IP <#{ip}>" + server.associate_address ip + end + + def get_public_private_ips(server) + begin + pub = server.public_ip_addresses + priv = server.private_ip_addresses + rescue Fog::OpenStack::Compute::NotFound, Excon::Errors::Forbidden + # See Fog issue: https://github.com/fog/fog/issues/2160 + addrs = server.addresses + addrs['public'] && pub = addrs['public'].map { |i| i['addr'] } + addrs['private'] && priv = addrs['private'].map { |i| i['addr'] } + end + [pub, priv] + end + + def get_ip(server) + if config[:floating_ip] + debug "Using floating ip: #{config[:floating_ip]}" + return config[:floating_ip] + end + + # make sure we have the latest info + info 'Waiting for network information to be available...' + begin + w = server.wait_for { !addresses.empty? } + debug "Waited #{w[:duration]} seconds for network information." + rescue Fog::Errors::TimeoutError + raise ActionFailed, 'Could not get network information (timed out)' + end + + # should also work for private networks + if config[:openstack_network_name] + debug "Using configured net: #{config[:openstack_network_name]}" + return filter_ips(server.addresses[config[:openstack_network_name]]).first['addr'] + end + + pub, priv = get_public_private_ips(server) + priv = server.ip_addresses if Array(pub).empty? && Array(priv).empty? + pub, priv = parse_ips(pub, priv) + pub[config[:public_ip_order].to_i] || + priv[config[:private_ip_order].to_i] || + raise(ActionFailed, 'Could not find an IP') + end + + def filter_ips(addresses) + if config[:use_ipv6] + addresses.select { |i| IPAddr.new(i['addr']).ipv6? } + else + addresses.select { |i| IPAddr.new(i['addr']).ipv4? } + end + end + + def parse_ips(pub, priv) + pub = Array(pub) + priv = Array(priv) + if config[:use_ipv6] + [pub, priv].each { |n| n.select! { |i| IPAddr.new(i).ipv6? } } + else + [pub, priv].each { |n| n.select! { |i| IPAddr.new(i).ipv4? } } + end + [pub, priv] + end + end + end + end +end diff --git a/lib/kitchen/driver/openstack/server_helper.rb b/lib/kitchen/driver/openstack/server_helper.rb new file mode 100644 index 0000000..d66901d --- /dev/null +++ b/lib/kitchen/driver/openstack/server_helper.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +# +# Author:: Jonathan Hartman () +# Author:: JJ Asghar () +# Author:: Lance Albertson () +# +# Copyright:: (C) 2013-2015, Jonathan Hartman +# Copyright:: (C) 2015-2020, Chef Software Inc. +# 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. + +module Kitchen + module Driver + class Openstack < Kitchen::Driver::Base + # Server creation and resource finders (image, flavor, network) + module ServerHelper + private + + def create_server + server_def = init_configuration + raise(ActionFailed, 'Cannot specify both network_ref and network_id') if config[:network_id] && config[:network_ref] + + if config[:network_id] + networks = [].push(config[:network_id]) + server_def[:nics] = networks.flatten.map do |net_id| + { 'net_id' => net_id } + end + elsif config[:network_ref] + networks = [].push(config[:network_ref]) + server_def[:nics] = networks.flatten.map do |net| + { 'net_id' => find_network(net).id } + end + end + + if config[:block_device_mapping] + server_def[:block_device_mapping] = get_bdm(config) + end + + %i( + security_groups + key_name + user_data + config_drive + metadata + ).each do |c| + server_def[c] = optional_config(c) if config[c] + end + + if config[:cloud_config] + raise(ActionFailed, 'Cannot specify both cloud_config and user_data') if config[:user_data] + + server_def[:user_data] = YAML.dump(Kitchen::Util.stringified_hash(config[:cloud_config])).gsub(/^---\n/, "#cloud-config\n") + end + + # Can't use the Fog bootstrap and/or setup methods here; they require a + # public IP address that can't be guaranteed to exist across all + # OpenStack deployments (e.g. TryStack ARM only has private IPs). + compute.servers.create(server_def) + end + + def init_configuration + raise(ActionFailed, 'Cannot specify both image_ref and image_id') if config[:image_id] && config[:image_ref] + raise(ActionFailed, 'Cannot specify both flavor_ref and flavor_id') if config[:flavor_id] && config[:flavor_ref] + + { + name: config[:server_name], + image_ref: config[:image_id] || find_image(config[:image_ref]).id, + flavor_ref: config[:flavor_id] || find_flavor(config[:flavor_ref]).id, + availability_zone: config[:availability_zone], + } + end + + def optional_config(c) + case c + when :security_groups + config[c] if config[c].is_a?(Array) + when :user_data + File.read(config[c]) if File.exist?(config[c]) + else + config[c] + end + end + + def find_image(image_ref) + image = find_matching(compute.images, image_ref) + raise(ActionFailed, 'Image not found') unless image + + debug "Selected image: #{image.id} #{image.name}" + image + end + + def find_flavor(flavor_ref) + flavor = find_matching(compute.flavors, flavor_ref) + raise(ActionFailed, 'Flavor not found') unless flavor + + debug "Selected flavor: #{flavor.id} #{flavor.name}" + flavor + end + + def find_network(network_ref) + net = find_matching(network.networks.all, network_ref) + raise(ActionFailed, 'Network not found') unless net + + debug "Selected net: #{net.id} #{net.name}" + net + end + + def find_matching(collection, name) + name = name.to_s + if name.start_with?('/') && name.end_with?('/') + regex = Regexp.new(name[1...-1]) + # check for regex name match + collection.each { |single| return single if regex&.match?(single.name) } + else + # check for exact id match + collection.each { |single| return single if single.id == name } + # check for exact name match + collection.each { |single| return single if single.name == name } + end + nil + end + end + end + end +end diff --git a/lib/kitchen/driver/openstack/volume.rb b/lib/kitchen/driver/openstack/volume.rb index 38115ca..80e88ce 100644 --- a/lib/kitchen/driver/openstack/volume.rb +++ b/lib/kitchen/driver/openstack/volume.rb @@ -3,7 +3,7 @@ # # Author:: Jonathan Hartman () # -# Copyright (C) 2013-2015, Jonathan Hartman +# Copyright:: (C) 2013-2015, Jonathan Hartman # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,8 +17,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -require "fog/openstack" -require "kitchen" +require 'fog/openstack' +require 'kitchen' module Kitchen module Driver @@ -28,7 +28,7 @@ class Openstack < Kitchen::Driver::Base # # @author Liam Haworth class Volume - @@default_creation_timeout = 60 + DEFAULT_CREATION_TIMEOUT = 60 def initialize(logger) @logger = logger @@ -41,26 +41,26 @@ def volume(openstack_server) def create_volume(config, os) opt = {} bdm = config[:block_device_mapping] - vanilla_options = %i{snapshot_id imageRef volume_type - source_volid availability_zone} + vanilla_options = %i(snapshot_id imageRef volume_type + source_volid availability_zone) vanilla_options.select { |o| bdm[o] }.each do |key| opt[key] = bdm[key] end - @logger.info "Creating Volume..." + @logger.info 'Creating Volume...' resp = volume(os) - .create_volume( + .create_volume( "#{config[:server_name]}-volume", "#{config[:server_name]} volume", bdm[:volume_size], opt ) - vol_id = resp[:body]["volume"]["id"] + vol_id = resp[:body]['volume']['id'] # Get Volume Model to make waiting for ready easy vol_model = volume(os).volumes.first { |x| x.id == vol_id } # Use default creation timeout or user supplied - creation_timeout = @@default_creation_timeout + creation_timeout = DEFAULT_CREATION_TIMEOUT if bdm.key?(:creation_timeout) creation_timeout = bdm[:creation_timeout] end @@ -68,7 +68,7 @@ def create_volume(config, os) @logger.debug "Waiting for volume to be ready for #{creation_timeout} seconds" vol_model.wait_for(creation_timeout) do sleep(1) - raise("Failed to make volume") if status.casecmp("error".downcase) == 0 + raise('Failed to make volume') if status.casecmp('error'.downcase) == 0 ready? end @@ -80,7 +80,7 @@ def create_volume(config, os) sleep(attach_timeout) end - @logger.debug "Volume Ready" + @logger.debug 'Volume Ready' vol_id end diff --git a/lib/kitchen/driver/openstack_version.rb b/lib/kitchen/driver/openstack_version.rb index 60fdd10..0c7c19f 100644 --- a/lib/kitchen/driver/openstack_version.rb +++ b/lib/kitchen/driver/openstack_version.rb @@ -3,8 +3,8 @@ # # Author:: Jonathan Hartman () # -# Copyright (C) 2013-2015, Jonathan Hartman -# Copyright (C) 2015-2021, Chef Software Inc +# Copyright:: (C) 2013-2015, Jonathan Hartman +# Copyright:: (C) 2015-2021, Chef Software Inc # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -23,6 +23,6 @@ module Kitchen # # @author Jonathan Hartman module Driver - OPENSTACK_VERSION = "6.2.2" + OPENSTACK_VERSION = '6.2.2' end end diff --git a/spec/kitchen/driver/openstack_spec.rb b/spec/kitchen/driver/openstack_spec.rb index 28e90e1..b47e674 100755 --- a/spec/kitchen/driver/openstack_spec.rb +++ b/spec/kitchen/driver/openstack_spec.rb @@ -823,7 +823,7 @@ before(:each) do allow(File).to receive(:exist?).and_return(true) - allow(File).to receive(:open).and_return(data) + allow(File).to receive(:read).and_return(data) end it "passes file contents" do From a2499aad968b920e204c431b050a41b424e00f4a Mon Sep 17 00:00:00 2001 From: Lance Albertson Date: Thu, 2 Apr 2026 17:11:23 -0700 Subject: [PATCH 2/4] fix: switch linting from cookstyle to chefstyle Update Rakefile to use 'cookstyle --chefstyle' instead of RuboCop::RakeTask with the unsupported --chefstyle flag. The --chefstyle flag is handled by the cookstyle binary, not by RuboCop directly. Remove the require directive from .rubocop.yml since --chefstyle handles config loading internally. Auto-correct all chefstyle offenses (double-quoted strings, %i{} delimiters). Signed-off-by: Lance Albertson --- .rubocop.yml | 3 -- Rakefile | 6 ++-- lib/kitchen/driver/openstack.rb | 30 +++++++++---------- lib/kitchen/driver/openstack/config.rb | 18 +++++------ lib/kitchen/driver/openstack/helpers.rb | 12 ++++---- lib/kitchen/driver/openstack/networking.rb | 24 +++++++-------- lib/kitchen/driver/openstack/server_helper.rb | 24 +++++++-------- lib/kitchen/driver/openstack/volume.rb | 18 +++++------ lib/kitchen/driver/openstack_version.rb | 2 +- 9 files changed, 67 insertions(+), 70 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index df528bf..7955d9b 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,7 +1,4 @@ --- -require: - - cookstyle - AllCops: TargetRubyVersion: 3.1 Include: diff --git a/Rakefile b/Rakefile index eec8159..0925ee3 100644 --- a/Rakefile +++ b/Rakefile @@ -16,9 +16,9 @@ end begin require 'cookstyle' - require 'rubocop/rake_task' - RuboCop::RakeTask.new(:style) do |task| - task.options += ['--display-cop-names', '--no-color'] + desc 'Run cookstyle with chefstyle rules' + task :style do + sh 'cookstyle --chefstyle --display-cop-names' end rescue LoadError puts 'cookstyle is not available. gem install cookstyle to do style checking.' diff --git a/lib/kitchen/driver/openstack.rb b/lib/kitchen/driver/openstack.rb index b78cbe8..3067b41 100755 --- a/lib/kitchen/driver/openstack.rb +++ b/lib/kitchen/driver/openstack.rb @@ -21,15 +21,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -require 'kitchen' -require 'fog/openstack' -require 'yaml' -require_relative 'openstack_version' -require_relative 'openstack/config' -require_relative 'openstack/helpers' -require_relative 'openstack/networking' -require_relative 'openstack/server_helper' -require_relative 'openstack/volume' +require "kitchen" +require "fog/openstack" +require "yaml" +require_relative "openstack_version" +require_relative "openstack/config" +require_relative "openstack/helpers" +require_relative "openstack/networking" +require_relative "openstack/server_helper" +require_relative "openstack/volume" module Kitchen module Driver @@ -46,7 +46,7 @@ class Openstack < Kitchen::Driver::Base default_config :server_name, nil default_config :server_name_prefix, nil default_config :key_name, nil - default_config :port, '22' + default_config :port, "22" default_config :use_ipv6, false default_config :openstack_project_name, nil default_config :openstack_region, nil @@ -110,13 +110,13 @@ def destroy(state) unless server.nil? if config[:floating_ip_pool] && config[:allocate_floating_ip] - info 'Retrieve the floating IP' + info "Retrieve the floating IP" pub, priv = get_public_private_ips(server) pub, = parse_ips(pub, priv) pub_ip = pub[config[:public_ip_order].to_i] || nil if pub_ip info "Retrieve the ID of floating IP <#{pub_ip}>" - floating_ip_id = network.list_floating_ips(floating_ip_address: pub_ip).body['floatingips'][0]['id'] + floating_ip_id = network.list_floating_ips(floating_ip_address: pub_ip).body["floatingips"][0]["id"] network.delete_floating_ip(floating_ip_id) info "OpenStack Floating IP <#{pub_ip}> released." end @@ -141,17 +141,17 @@ def openstack_server end def required_server_settings - %i(openstack_username openstack_api_key openstack_auth_url openstack_domain_id) + %i{openstack_username openstack_api_key openstack_auth_url openstack_domain_id} end def optional_server_settings Fog::OpenStack::Compute.recognized.select do |k| - k.to_s.start_with?('openstack') + k.to_s.start_with?("openstack") end - required_server_settings end def connection_options - %i(read_timeout write_timeout connect_timeout) + %i{read_timeout write_timeout connect_timeout} end def network diff --git a/lib/kitchen/driver/openstack/config.rb b/lib/kitchen/driver/openstack/config.rb index bc51245..6674c54 100644 --- a/lib/kitchen/driver/openstack/config.rb +++ b/lib/kitchen/driver/openstack/config.rb @@ -49,11 +49,11 @@ def config_server_name # Total: 63 def default_name [ - instance.name.gsub(/\W/, '')[0..14], - ((Etc.getpwuid ? Etc.getpwuid.name : Etc.getlogin) || 'nologin').gsub(/\W/, '')[0..14], - Socket.gethostname.gsub(/\W/, '')[0..22], + instance.name.gsub(/\W/, "")[0..14], + ((Etc.getpwuid ? Etc.getpwuid.name : Etc.getlogin) || "nologin").gsub(/\W/, "")[0..14], + Socket.gethostname.gsub(/\W/, "")[0..22], Array.new(7) { rand(36).to_s(36) }.join, - ].join('-') + ].join("-") end def server_name_prefix(server_name_prefix) @@ -67,18 +67,18 @@ def server_name_prefix(server_name_prefix) # Max: 63 # if server_name_prefix.length > 54 - warn 'Server name prefix too long, truncated to 54 characters' + warn "Server name prefix too long, truncated to 54 characters" server_name_prefix = server_name_prefix[0..53] end - server_name_prefix.gsub!(/\W/, '') + server_name_prefix.gsub!(/\W/, "") if server_name_prefix.empty? - warn 'Server name prefix empty or invalid; using fully generated name' + warn "Server name prefix empty or invalid; using fully generated name" default_name else - random_suffix = ('a'..'z').to_a.sample(8).join - server_name_prefix + '-' + random_suffix + random_suffix = ("a".."z").to_a.sample(8).join + server_name_prefix + "-" + random_suffix end end end diff --git a/lib/kitchen/driver/openstack/helpers.rb b/lib/kitchen/driver/openstack/helpers.rb index a1c3f53..0edfec0 100644 --- a/lib/kitchen/driver/openstack/helpers.rb +++ b/lib/kitchen/driver/openstack/helpers.rb @@ -21,7 +21,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -require 'ohai' unless defined?(Ohai::System) +require "ohai" unless defined?(Ohai::System) module Kitchen module Driver @@ -32,14 +32,14 @@ module Helpers def add_ohai_hint(state) if bourne_shell? - info 'Adding OpenStack hint for ohai' + info "Adding OpenStack hint for ohai" mkdir_cmd = "sudo mkdir -p #{hints_path}" touch_cmd = "sudo bash -c 'echo {} > #{hints_path}/openstack.json'" instance.transport.connection(state).execute( "#{mkdir_cmd} && #{touch_cmd}" ) elsif windows_os? - info 'Adding OpenStack hint for ohai' + info "Adding OpenStack hint for ohai" touch_cmd = "New-Item #{hints_path}\\openstack.json" touch_cmd_args = "-Value '{}' -Force -Type file" instance.transport.connection(state).execute( @@ -53,7 +53,7 @@ def hints_path end def disable_ssl_validation - require 'excon' unless defined?(Excon) + require "excon" unless defined?(Excon) Excon.defaults[:ssl_verify_peer] = false end @@ -62,7 +62,7 @@ def wait_for_server(state) info "Sleeping for #{config[:server_wait]} seconds to let your server start up..." countdown(config[:server_wait]) end - info 'Waiting for server to be ready...' + info "Waiting for server to be ready..." instance.transport.connection(state).wait_until_ready rescue error "Server #{state[:hostname]} (#{state[:server_id]}) not reachable. Destroying server..." @@ -73,7 +73,7 @@ def wait_for_server(state) def countdown(seconds) date1 = Time.now + seconds while Time.now < date1 - Kernel.print '.' + Kernel.print "." sleep 10 end end diff --git a/lib/kitchen/driver/openstack/networking.rb b/lib/kitchen/driver/openstack/networking.rb index 867f142..affd5bb 100644 --- a/lib/kitchen/driver/openstack/networking.rb +++ b/lib/kitchen/driver/openstack/networking.rb @@ -21,7 +21,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -require 'ipaddr' unless defined?(IPAddr) +require "ipaddr" unless defined?(IPAddr) module Kitchen module Driver @@ -37,11 +37,11 @@ def attach_ip_from_pool(server, pool) info "Attaching floating IP from <#{pool}> pool" if config[:allocate_floating_ip] network_id = network - .list_networks( + .list_networks( name: pool - ).body['networks'][0]['id'] + ).body["networks"][0]["id"] resp = network.create_floating_ip(network_id) - ip = resp.body['floatingip']['floating_ip_address'] + ip = resp.body["floatingip"]["floating_ip_address"] info "Created floating IP <#{ip}> from <#{pool}> pool" config[:floating_ip] = ip else @@ -70,8 +70,8 @@ def get_public_private_ips(server) rescue Fog::OpenStack::Compute::NotFound, Excon::Errors::Forbidden # See Fog issue: https://github.com/fog/fog/issues/2160 addrs = server.addresses - addrs['public'] && pub = addrs['public'].map { |i| i['addr'] } - addrs['private'] && priv = addrs['private'].map { |i| i['addr'] } + addrs["public"] && pub = addrs["public"].map { |i| i["addr"] } + addrs["private"] && priv = addrs["private"].map { |i| i["addr"] } end [pub, priv] end @@ -83,18 +83,18 @@ def get_ip(server) end # make sure we have the latest info - info 'Waiting for network information to be available...' + info "Waiting for network information to be available..." begin w = server.wait_for { !addresses.empty? } debug "Waited #{w[:duration]} seconds for network information." rescue Fog::Errors::TimeoutError - raise ActionFailed, 'Could not get network information (timed out)' + raise ActionFailed, "Could not get network information (timed out)" end # should also work for private networks if config[:openstack_network_name] debug "Using configured net: #{config[:openstack_network_name]}" - return filter_ips(server.addresses[config[:openstack_network_name]]).first['addr'] + return filter_ips(server.addresses[config[:openstack_network_name]]).first["addr"] end pub, priv = get_public_private_ips(server) @@ -102,14 +102,14 @@ def get_ip(server) pub, priv = parse_ips(pub, priv) pub[config[:public_ip_order].to_i] || priv[config[:private_ip_order].to_i] || - raise(ActionFailed, 'Could not find an IP') + raise(ActionFailed, "Could not find an IP") end def filter_ips(addresses) if config[:use_ipv6] - addresses.select { |i| IPAddr.new(i['addr']).ipv6? } + addresses.select { |i| IPAddr.new(i["addr"]).ipv6? } else - addresses.select { |i| IPAddr.new(i['addr']).ipv4? } + addresses.select { |i| IPAddr.new(i["addr"]).ipv4? } end end diff --git a/lib/kitchen/driver/openstack/server_helper.rb b/lib/kitchen/driver/openstack/server_helper.rb index d66901d..d05c0b3 100644 --- a/lib/kitchen/driver/openstack/server_helper.rb +++ b/lib/kitchen/driver/openstack/server_helper.rb @@ -30,17 +30,17 @@ module ServerHelper def create_server server_def = init_configuration - raise(ActionFailed, 'Cannot specify both network_ref and network_id') if config[:network_id] && config[:network_ref] + raise(ActionFailed, "Cannot specify both network_ref and network_id") if config[:network_id] && config[:network_ref] if config[:network_id] networks = [].push(config[:network_id]) server_def[:nics] = networks.flatten.map do |net_id| - { 'net_id' => net_id } + { "net_id" => net_id } end elsif config[:network_ref] networks = [].push(config[:network_ref]) server_def[:nics] = networks.flatten.map do |net| - { 'net_id' => find_network(net).id } + { "net_id" => find_network(net).id } end end @@ -48,18 +48,18 @@ def create_server server_def[:block_device_mapping] = get_bdm(config) end - %i( + %i{ security_groups key_name user_data config_drive metadata - ).each do |c| + }.each do |c| server_def[c] = optional_config(c) if config[c] end if config[:cloud_config] - raise(ActionFailed, 'Cannot specify both cloud_config and user_data') if config[:user_data] + raise(ActionFailed, "Cannot specify both cloud_config and user_data") if config[:user_data] server_def[:user_data] = YAML.dump(Kitchen::Util.stringified_hash(config[:cloud_config])).gsub(/^---\n/, "#cloud-config\n") end @@ -71,8 +71,8 @@ def create_server end def init_configuration - raise(ActionFailed, 'Cannot specify both image_ref and image_id') if config[:image_id] && config[:image_ref] - raise(ActionFailed, 'Cannot specify both flavor_ref and flavor_id') if config[:flavor_id] && config[:flavor_ref] + raise(ActionFailed, "Cannot specify both image_ref and image_id") if config[:image_id] && config[:image_ref] + raise(ActionFailed, "Cannot specify both flavor_ref and flavor_id") if config[:flavor_id] && config[:flavor_ref] { name: config[:server_name], @@ -95,7 +95,7 @@ def optional_config(c) def find_image(image_ref) image = find_matching(compute.images, image_ref) - raise(ActionFailed, 'Image not found') unless image + raise(ActionFailed, "Image not found") unless image debug "Selected image: #{image.id} #{image.name}" image @@ -103,7 +103,7 @@ def find_image(image_ref) def find_flavor(flavor_ref) flavor = find_matching(compute.flavors, flavor_ref) - raise(ActionFailed, 'Flavor not found') unless flavor + raise(ActionFailed, "Flavor not found") unless flavor debug "Selected flavor: #{flavor.id} #{flavor.name}" flavor @@ -111,7 +111,7 @@ def find_flavor(flavor_ref) def find_network(network_ref) net = find_matching(network.networks.all, network_ref) - raise(ActionFailed, 'Network not found') unless net + raise(ActionFailed, "Network not found") unless net debug "Selected net: #{net.id} #{net.name}" net @@ -119,7 +119,7 @@ def find_network(network_ref) def find_matching(collection, name) name = name.to_s - if name.start_with?('/') && name.end_with?('/') + if name.start_with?("/") && name.end_with?("/") regex = Regexp.new(name[1...-1]) # check for regex name match collection.each { |single| return single if regex&.match?(single.name) } diff --git a/lib/kitchen/driver/openstack/volume.rb b/lib/kitchen/driver/openstack/volume.rb index 80e88ce..853b3dd 100644 --- a/lib/kitchen/driver/openstack/volume.rb +++ b/lib/kitchen/driver/openstack/volume.rb @@ -17,8 +17,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -require 'fog/openstack' -require 'kitchen' +require "fog/openstack" +require "kitchen" module Kitchen module Driver @@ -41,20 +41,20 @@ def volume(openstack_server) def create_volume(config, os) opt = {} bdm = config[:block_device_mapping] - vanilla_options = %i(snapshot_id imageRef volume_type - source_volid availability_zone) + vanilla_options = %i{snapshot_id imageRef volume_type + source_volid availability_zone} vanilla_options.select { |o| bdm[o] }.each do |key| opt[key] = bdm[key] end - @logger.info 'Creating Volume...' + @logger.info "Creating Volume..." resp = volume(os) - .create_volume( + .create_volume( "#{config[:server_name]}-volume", "#{config[:server_name]} volume", bdm[:volume_size], opt ) - vol_id = resp[:body]['volume']['id'] + vol_id = resp[:body]["volume"]["id"] # Get Volume Model to make waiting for ready easy vol_model = volume(os).volumes.first { |x| x.id == vol_id } @@ -68,7 +68,7 @@ def create_volume(config, os) @logger.debug "Waiting for volume to be ready for #{creation_timeout} seconds" vol_model.wait_for(creation_timeout) do sleep(1) - raise('Failed to make volume') if status.casecmp('error'.downcase) == 0 + raise("Failed to make volume") if status.casecmp("error".downcase) == 0 ready? end @@ -80,7 +80,7 @@ def create_volume(config, os) sleep(attach_timeout) end - @logger.debug 'Volume Ready' + @logger.debug "Volume Ready" vol_id end diff --git a/lib/kitchen/driver/openstack_version.rb b/lib/kitchen/driver/openstack_version.rb index 0c7c19f..9c287b8 100644 --- a/lib/kitchen/driver/openstack_version.rb +++ b/lib/kitchen/driver/openstack_version.rb @@ -23,6 +23,6 @@ module Kitchen # # @author Jonathan Hartman module Driver - OPENSTACK_VERSION = '6.2.2' + OPENSTACK_VERSION = "6.2.2" end end From 7ff3ec5260cb801a70c4f6653e289c43a742fa25 Mon Sep 17 00:00:00 2001 From: Lance Albertson Date: Thu, 2 Apr 2026 17:11:41 -0700 Subject: [PATCH 3/4] chore: add GitHub Copilot instructions for the project Signed-off-by: Lance Albertson --- .github/copilot-instructions.md | 37 +++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..4a36a5b --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,37 @@ +# Project Guidelines + +## Overview + +kitchen-openstack is a Test Kitchen driver for OpenStack. It provisions and destroys Nova instances using the Fog OpenStack library. + +## Code Style + +- Ruby 3.1+ required +- All files must start with `# frozen_string_literal: true` +- Follow Cookstyle (Chef's RuboCop) conventions — config in `.rubocop.yml` +- Spec files are excluded from linting + +## Architecture + +- Driver class: `Kitchen::Driver::Openstack` in `lib/kitchen/driver/openstack.rb` — extends `Kitchen::Driver::Base` (Driver API v2) +- 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 + +## Build and Test + +```bash +bundle install +bundle exec rake # runs tests + style + stats (default) +bundle exec rake test # unit tests only (RSpec) +bundle exec rake style # Cookstyle lint +bundle exec rake quality # style + stats +``` + +## Conventions + +- Use `Fog::OpenStack::Compute` and `Fog::OpenStack::Network` for cloud interactions +- Thread safety: use `Mutex` for shared resource pools (e.g., floating IP allocation) +- Resource finders (`find_image`, `find_flavor`, `find_network`) support regex matching via `/pattern/` syntax +- Test with RSpec 3 using `let` fixtures, `double` mocks, and `allow_any_instance_of` for Kitchen internals +- Release automation via Release Please — version bumps go in `lib/kitchen/driver/openstack_version.rb` From 06fe004b749a2f5b0f351b55825edcd764a3f1e6 Mon Sep 17 00:00:00 2001 From: Lance Albertson Date: Fri, 3 Apr 2026 08:24:49 -0700 Subject: [PATCH 4/4] Move co-pilot instructions to AGENTS.md to make it more generalized Signed-off-by: Lance Albertson --- .github/copilot-instructions.md => AGENTS.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/copilot-instructions.md => AGENTS.md (100%) diff --git a/.github/copilot-instructions.md b/AGENTS.md similarity index 100% rename from .github/copilot-instructions.md rename to AGENTS.md