diff --git a/lib/chef-cli/command/gem.rb b/lib/chef-cli/command/gem.rb index 0116d3e4..c0695e5b 100644 --- a/lib/chef-cli/command/gem.rb +++ b/lib/chef-cli/command/gem.rb @@ -16,10 +16,12 @@ require_relative "base" require_relative "../dist" +require_relative "../licensing/base" require "rubygems" unless defined?(Gem) require "rubygems/gem_runner" require "rubygems/exceptions" require "fileutils" unless defined?(FileUtils) +require "uri" unless defined?(URI) module ChefCLI module Command @@ -27,8 +29,15 @@ module Command class GemForwarder < ChefCLI::Command::Base banner "Usage: #{ChefCLI::Dist::EXEC} gem GEM_COMMANDS_AND_OPTIONS" + CHEF_GEM_SOURCE_HOST = "rubygems.chef.io".freeze + RUBYGEMS_ORG_HOST = "rubygems.org".freeze + + # gem subcommands that fetch from remote sources and need the Chef source configured. + PREMIUM_SOURCE_COMMANDS = %w{install i search s fetch update download}.freeze + def run(params) setup_gem_environment if habitat_gem_home_enabled? + ensure_chef_gem_source(params) retval = Gem::GemRunner.new.run(params.clone) retval.nil? || retval rescue Gem::SystemExitException => e @@ -45,6 +54,80 @@ def needs_version?(_params) private + # Checks gem sources before any remote-fetching command and ensures the + # Chef Premium RubyGem server is configured: + # - Chef source present with valid v1 credentials → proceed. + # - Non-standard source present (incl. file://) → assume airgap, warn and skip. + # - Only rubygems.org (or unauthenticated chef source) → obtain license key and run `gem sources --add`. + def ensure_chef_gem_source(params) + return unless premium_source_command?(params) + return if chef_gem_source_configured? + + custom = non_standard_sources + unless custom.empty? + err("WARN: A custom gem source (#{custom.join(", ")}) is already configured; assuming an air-gapped environment.") + err("WARN: The Chef Premium RubyGem source was not added. Premium extensions may be unavailable.") + return + end + + license_key = chef_license_key + if license_key.nil? || license_key.empty? + err("WARN: No valid Chef license key was found, so the Chef Premium RubyGem source was not configured.") + err("WARN: You will not be able to access premium extensions. Run `#{ChefCLI::Dist::EXEC} license add` to configure a license.") + return + end + + add_chef_gem_source(license_key) + end + + # Returns true when the first non-flag token is a remote-fetching subcommand. + def premium_source_command?(params) + command = params.find { |p| !p.to_s.start_with?("-") } + PREMIUM_SOURCE_COMMANDS.include?(command) + end + + # Returns true only when rubygems.chef.io is configured with valid v1 credentials. + # A bare https://rubygems.chef.io entry (no user/password) is not considered configured. + def chef_gem_source_configured? + Gem.sources.any? do |source| + uri = URI.parse(source.to_s) + uri.host == CHEF_GEM_SOURCE_HOST && uri.user == "v1" && !uri.password.to_s.empty? + rescue URI::InvalidURIError + false + end + end + + # Returns sources that are neither rubygems.org nor rubygems.chef.io. + # Sources whose URI has no host (e.g. file://) or cannot be parsed are + # treated as non-standard so the code errs on the side of not modifying sources. + def non_standard_sources + Gem.sources.reject do |source| + uri = URI.parse(source.to_s) + [RUBYGEMS_ORG_HOST, CHEF_GEM_SOURCE_HOST].include?(uri.host) + rescue URI::InvalidURIError + false + end + end + + # Persists the Chef Premium RubyGem server via `gem sources --add` so the + # configuration survives the current process. + def add_chef_gem_source(license_key) + source_url = "https://v1:#{license_key}@#{CHEF_GEM_SOURCE_HOST}" + Gem::GemRunner.new.run(["sources", "--add", source_url]) + rescue Gem::SystemExitException => e + err("WARN: Failed to add the Chef Premium RubyGem source (exit #{e.exit_code}).") unless e.exit_code == 0 + end + + # Fetches the first Chef license key from env, CLI args, or persisted storage. + # Does not prompt the terminal — if no key is found nil is returned and the + # caller warns the user to run `chef license add`. + def chef_license_key + keys = ChefLicensing.license_keys + keys.is_a?(Array) ? keys.first : nil + rescue StandardError + nil + end + # Detects whether the user gem home feature is enabled. # This is set via CHEF_GEM_HOME_ENABLED in the Habitat plan's # do_setup_environment/Invoke-SetupEnvironment, or falls back to diff --git a/spec/unit/command/gem_spec.rb b/spec/unit/command/gem_spec.rb index 2178dbb7..987b8539 100644 --- a/spec/unit/command/gem_spec.rb +++ b/spec/unit/command/gem_spec.rb @@ -26,6 +26,8 @@ before do allow(Gem::GemRunner).to receive(:new).and_return(gem_runner) + # Avoid touching the licensing service / network in unrelated examples. + allow(command_instance).to receive(:ensure_chef_gem_source) end it "has a usage banner" do @@ -49,6 +51,13 @@ allow(ENV).to receive(:[]).with("CHEF_GEM_HOME_ENABLED").and_return(nil) end + # TC-11: source auto-configuration applies regardless of Habitat mode + it "calls ensure_chef_gem_source before forwarding to GemRunner" do + expect(command_instance).to receive(:ensure_chef_gem_source).with(%w{install knife}).ordered + expect(gem_runner).to receive(:run).with(%w{install knife}).and_return(true).ordered + command_instance.run(%w{install knife}) + end + it "forwards params to Gem::GemRunner" do expect(gem_runner).to receive(:run).with(%w(install knife)).and_return(true) expect(command_instance.run(%w(install knife))).to eq(true) @@ -172,4 +181,207 @@ ) end end + + describe "#ensure_chef_gem_source" do + let(:license_key) { "tmns-00000000-0000-0000-0000-000000000000-0000" } + + before do + allow(command_instance).to receive(:ensure_chef_gem_source).and_call_original + end + + def stub_sources(*urls) + allow(Gem).to receive(:sources).and_return(urls) + end + + context "when the command does not download from remote sources" do + before { stub_sources("https://rubygems.org/") } + + it "does nothing for non-install/search commands" do + expect(ChefLicensing).not_to receive(:license_keys) + expect(command_instance).not_to receive(:add_chef_gem_source) + command_instance.send(:ensure_chef_gem_source, %w{list}) + end + end + + context "when the Chef Premium RubyGem source is already configured with credentials" do + before do + # Stub the method directly — URL construction with credentials is tested in #chef_gem_source_configured? + allow(command_instance).to receive(:chef_gem_source_configured?).and_return(true) + end + + it "does not attempt to add it again" do + expect(ChefLicensing).not_to receive(:license_keys) + expect(command_instance).not_to receive(:add_chef_gem_source) + command_instance.send(:ensure_chef_gem_source, %w{install knife}) + end + end + + # Bug fix: unauthenticated rubygems.chef.io must not be treated as configured + context "when rubygems.chef.io is present but without credentials" do + before do + stub_sources("https://rubygems.org/", "https://rubygems.chef.io") + allow(ChefLicensing).to receive(:license_keys).and_return([license_key]) + end + + it "treats it as not configured and adds the authenticated source" do + expect(command_instance).to receive(:add_chef_gem_source).with(license_key) + command_instance.send(:ensure_chef_gem_source, %w{install knife}) + end + end + + context "when only the default RubyGems.org source is configured" do + before do + stub_sources("https://rubygems.org/") + allow(ChefLicensing).to receive(:license_keys).and_return([license_key]) + end + + it "adds the Chef source built from the license key" do + expect(command_instance).to receive(:add_chef_gem_source).with(license_key) + command_instance.send(:ensure_chef_gem_source, %w{install knife}) + end + + # TC-13: license_keys (not fetch_and_persist) is called so no interactive prompt blocks gem commands + it "uses license_keys to retrieve the license key without prompting" do + expect(ChefLicensing).to receive(:license_keys).and_return([license_key]) + allow(command_instance).to receive(:add_chef_gem_source) + command_instance.send(:ensure_chef_gem_source, %w{install knife}) + end + + it "also triggers for the search command" do + expect(command_instance).to receive(:add_chef_gem_source).with(license_key) + command_instance.send(:ensure_chef_gem_source, %w{search knife}) + end + end + + # TC-06: only a custom source present with NO rubygems.org + context "when only a custom source is configured (no rubygems.org)" do + before { stub_sources("https://airgap.internal/gems") } + + it "assumes air-gapped and does not add the Chef source" do + expect(ChefLicensing).not_to receive(:license_keys) + expect(command_instance).not_to receive(:add_chef_gem_source) + allow(command_instance).to receive(:err) + command_instance.send(:ensure_chef_gem_source, %w{install plugin}) + end + + it "warns about air-gapped environment" do + allow(command_instance).to receive(:err) + expect(command_instance).to receive(:err).with(/air-gapped/) + command_instance.send(:ensure_chef_gem_source, %w{install plugin}) + end + end + + # Bug fix: file:// sources have host=="" and must not be silently dropped from airgap detection + context "when a file:// local source is configured" do + before { stub_sources("file:///var/cache/gems") } + + it "treats it as non-standard and does not add the Chef source" do + expect(ChefLicensing).not_to receive(:license_keys) + expect(command_instance).not_to receive(:add_chef_gem_source) + allow(command_instance).to receive(:err) + command_instance.send(:ensure_chef_gem_source, %w{install plugin}) + end + + it "warns about air-gapped environment" do + allow(command_instance).to receive(:err) + expect(command_instance).to receive(:err).with(/air-gapped/) + command_instance.send(:ensure_chef_gem_source, %w{install plugin}) + end + end + + # TC-07: rubygems.org AND a custom source present + context "when a custom non-chef, non-rubygems source is configured" do + before { stub_sources("https://rubygems.org/", "https://airgap.internal/gems") } + + it "assumes an air-gapped mirror and does not add the Chef source" do + expect(ChefLicensing).not_to receive(:license_keys) + expect(command_instance).not_to receive(:add_chef_gem_source) + allow(command_instance).to receive(:err) + command_instance.send(:ensure_chef_gem_source, %w{install knife}) + end + + it "warns the user about the air-gapped assumption" do + allow(command_instance).to receive(:err) + expect(command_instance).to receive(:err).with(/air-gapped/) + command_instance.send(:ensure_chef_gem_source, %w{install knife}) + end + end + + # TC-05: no license key — warn and allow install to proceed (graceful degradation) + context "when no license key can be obtained" do + before do + stub_sources("https://rubygems.org/") + allow(ChefLicensing).to receive(:license_keys).and_return([]) + end + + it "does not add a source and warns about premium extensions" do + expect(command_instance).not_to receive(:add_chef_gem_source) + allow(command_instance).to receive(:err) + expect(command_instance).to receive(:err).with(/premium extensions/) + command_instance.send(:ensure_chef_gem_source, %w{install knife}) + end + + it "does not raise an error so the install proceeds against rubygems.org" do + allow(command_instance).to receive(:err) + expect { command_instance.send(:ensure_chef_gem_source, %w{install knife}) }.not_to raise_error + end + end + + context "when fetching the license key raises an error" do + before do + stub_sources("https://rubygems.org/") + allow(ChefLicensing).to receive(:license_keys).and_raise(StandardError) + end + + it "treats it as no license key and does not add a source" do + expect(command_instance).not_to receive(:add_chef_gem_source) + allow(command_instance).to receive(:err) + command_instance.send(:ensure_chef_gem_source, %w{install knife}) + end + end + end + + describe "#chef_gem_source_configured?" do + let(:license_key) { "tmns-00000000-0000-0000-0000-000000000000-0000" } + + def stub_sources(*urls) + allow(Gem).to receive(:sources).and_return(urls) + end + + it "returns true when the Chef source has v1 user and non-empty password" do + stub_sources("https://rubygems.org/", "https://v1:#{license_key}@rubygems.chef.io") + expect(command_instance.send(:chef_gem_source_configured?)).to be(true) + end + + it "returns false when the Chef source has no credentials" do + stub_sources("https://rubygems.org/", "https://rubygems.chef.io") + expect(command_instance.send(:chef_gem_source_configured?)).to be(false) + end + + it "returns false when no Chef source is present" do + stub_sources("https://rubygems.org/") + expect(command_instance.send(:chef_gem_source_configured?)).to be(false) + end + end + + describe "#add_chef_gem_source" do + let(:license_key) { "tmns-abc-123" } + let(:expected_source_url) { "https://v1:#{license_key}@rubygems.chef.io" } + let(:source_runner) { instance_double(Gem::GemRunner) } + + before do + allow(Gem::GemRunner).to receive(:new).and_return(source_runner) + end + + it "invokes gem sources --add with the premium URL" do + expect(source_runner).to receive(:run).with(["sources", "--add", expected_source_url]) + command_instance.send(:add_chef_gem_source, license_key) + end + + it "does not propagate a Gem::SystemExitException so the user command still runs" do + allow(source_runner).to receive(:run).and_raise(Gem::SystemExitException.new(1)) + allow(command_instance).to receive(:err) + expect { command_instance.send(:add_chef_gem_source, license_key) }.not_to raise_error + end + end end