diff --git a/lib/kitchen/driver/openstack.rb b/lib/kitchen/driver/openstack.rb index 22c7697d..8cf48695 100755 --- a/lib/kitchen/driver/openstack.rb +++ b/lib/kitchen/driver/openstack.rb @@ -218,6 +218,19 @@ def status(state) } end + # Checks the configuration for the mistakes that otherwise surface part + # way through +create+, once a server may already be building and + # billing. + # + # @param state [Hash] mutable instance and driver state + # @return [Boolean] true when a problem was reported + def doctor(state) # rubocop:disable Lint/UnusedMethodArgument + problems = credential_problems + image_flavor_problems + + problems.each { |problem| warn(problem) } + !problems.empty? + end + private # Looks a server up without turning an unreachable cloud into a failure. @@ -232,6 +245,54 @@ def lookup_server(server_id) nil end + # The three settings without which Keystone cannot authenticate. They are + # in +required_server_settings+, but that only means Fog is handed them + # even when nil -- nothing rejects a nil before the request goes out. + # + # @return [Array] one problem per unset setting, then an + # authentication check when all three are present + def credential_problems + missing = { + openstack_username: "OS_USERNAME", + openstack_api_key: "OS_PASSWORD", + openstack_auth_url: "OS_AUTH_URL", + }.filter_map do |key, env| + if config[key].to_s.empty? + "#{key} is not set: set it in kitchen.yml, export #{env}, or " \ + "put it in clouds.yaml." + end + end + return missing unless missing.empty? + + compute.servers.summary + [] + rescue ::StandardError => e + ["OpenStack rejected the configured credentials at " \ + "#{config[:openstack_auth_url]}: #{e.message}"] + end + + # The image and flavor pairs that +create+ refuses, checked here so the + # refusal arrives before the run starts rather than during it. + # + # @return [Array] the conflicting or missing selections + def image_flavor_problems + problems = [] + + if config[:image_id] && config[:image_ref] + problems << "Both image_id and image_ref are set; create accepts only one." + elsif config[:image_id].nil? && config[:image_ref].nil? + problems << "Neither image_id nor image_ref is set; there is no image to build from." + end + + if config[:flavor_id] && config[:flavor_ref] + problems << "Both flavor_id and flavor_ref are set; create accepts only one." + elsif config[:flavor_id].nil? && config[:flavor_ref].nil? + problems << "Neither flavor_id nor flavor_ref is set; there is no flavor to size the server with." + end + + problems + end + # Releases a floating IP back to its pool. # # A floating IP that Neutron no longer knows about is not an error worth diff --git a/spec/kitchen/driver/openstack_spec.rb b/spec/kitchen/driver/openstack_spec.rb index a90556f8..5ac30e9c 100755 --- a/spec/kitchen/driver/openstack_spec.rb +++ b/spec/kitchen/driver/openstack_spec.rb @@ -327,6 +327,114 @@ end end + describe "#doctor" do + let(:servers) { double("Fog servers collection", summary: []) } + let(:compute) { fog_compute(servers: servers) } + let(:config) do + { + openstack_username: "user", openstack_api_key: "secret", + openstack_auth_url: "https://keystone.example.com/v3", + image_id: "img-1", flavor_id: "flavor-1" + } + end + + before { allow(driver).to receive(:compute).and_return(compute) } + + def doctor_messages + messages = [] + allow(driver).to receive(:warn) { |m| messages << m } + [driver.doctor({}), messages] + end + + it "passes on a complete configuration" do + found, messages = doctor_messages + + expect(found).to be(false) + expect(messages).to be_empty + end + + context "with no credentials at all" do + let(:config) { { image_id: "img-1", flavor_id: "flavor-1" } } + + it "names each missing setting and where it can come from" do + found, messages = doctor_messages + + expect(found).to be(true) + joined = messages.join("\n") + expect(joined).to include("openstack_username is not set") + expect(joined).to include("OS_PASSWORD") + expect(joined).to include("clouds.yaml") + end + + it "does not also complain about a connection it never tried" do + _found, messages = doctor_messages + + expect(messages.join("\n")).not_to include("rejected the configured credentials") + end + end + + it "reports credentials Keystone rejects" do + allow(servers).to receive(:summary).and_raise(Excon::Errors::Unauthorized.new("401")) + + found, messages = doctor_messages + + expect(found).to be(true) + expect(messages.join("\n")).to include("rejected the configured credentials") + end + + context "with both image_id and image_ref" do + let(:config) do + { + openstack_username: "user", openstack_api_key: "secret", + openstack_auth_url: "https://keystone.example.com/v3", + image_id: "img-1", image_ref: "ubuntu", flavor_id: "flavor-1" + } + end + + it "reports the conflict create would raise on" do + found, messages = doctor_messages + + expect(found).to be(true) + expect(messages.join("\n")).to include("Both image_id and image_ref are set") + end + end + + context "with both flavor_id and flavor_ref" do + let(:config) do + { + openstack_username: "user", openstack_api_key: "secret", + openstack_auth_url: "https://keystone.example.com/v3", + image_id: "img-1", flavor_id: "flavor-1", flavor_ref: "m1.small" + } + end + + it "reports the conflict create would raise on" do + found, messages = doctor_messages + + expect(found).to be(true) + expect(messages.join("\n")).to include("Both flavor_id and flavor_ref are set") + end + end + + context "with neither an image nor a flavor selected" do + let(:config) do + { + openstack_username: "user", openstack_api_key: "secret", + openstack_auth_url: "https://keystone.example.com/v3" + } + end + + it "reports both gaps" do + found, messages = doctor_messages + + expect(found).to be(true) + joined = messages.join("\n") + expect(joined).to include("Neither image_id nor image_ref is set") + expect(joined).to include("Neither flavor_id nor flavor_ref is set") + end + end + end + describe "#destroy" do let(:state) { { server_id: "test123", hostname: "1.2.3.4" } } let(:servers) { double("Fog servers collection", get: server) }