summaryrefslogtreecommitdiff
path: root/lib/chef/provider/package/rubygems.rb
blob: e427cc0d24c2eaf475e39f852a2058a652162c2d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
#
# Author:: Adam Jacob (<adam@chef.io>)
# Author:: Daniel DeLeo (<dan@chef.io>)
# Copyright:: Copyright (c) Chef Software Inc.
# License:: Apache License, Version 2.0
#
# 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.
#

autoload :URI, "uri"
require_relative "../package"
require_relative "../../resource/package"
require_relative "../../mixin/get_source_from_package"
require_relative "../../mixin/which"
require "chef-utils/dist" unless defined?(ChefUtils::Dist)

# Class methods on Gem are defined in rubygems
autoload :Gem, "rubygems"
# Ruby 1.9's gem_prelude can interact poorly with loading the full rubygems
# explicitly like this. Make sure rubygems/specification is always last in this
# list
Gem.autoload :Version, "rubygems/version"
Gem.autoload :Dependency, "rubygems/dependency"
Gem.autoload :SpecFetcher, "rubygems/spec_fetcher"
Gem.autoload :Platform, "rubygems/platform"
Gem.autoload :Package, "rubygems/package"
Gem.autoload :DependencyInstaller, "rubygems/dependency_installer"
Gem.autoload :Uninstaller, "rubygems/uninstaller"
Gem.autoload :Specification, "rubygems/specification"

class Chef
  class Provider
    class Package
      class Rubygems < Chef::Provider::Package
        class GemEnvironment
          DEFAULT_UNINSTALLER_OPTS = { ignore: true, executables: true }.freeze

          def initialize(*args)
            super
            # HACK: trigger gem config load early. Otherwise it can get lazy
            # loaded during operations where we've set Gem.sources to an
            # alternate value and overwrite it with the defaults.
            Gem.configuration
          end

          # The paths where rubygems should search for installed gems.
          # Implemented by subclasses.
          def gem_paths
            raise NotImplementedError
          end

          # A rubygems source index containing the list of gemspecs for all
          # available gems in the gem installation.
          # Implemented by subclasses
          #
          # @return [Gem::SourceIndex]
          #
          def gem_source_index
            raise NotImplementedError
          end

          # A rubygems specification object containing the list of gemspecs for all
          # available gems in the gem installation.
          # Implemented by subclasses
          #
          # @return [Gem::Specification]
          #
          def gem_specification
            raise NotImplementedError
          end

          def rubygems_version
            raise NotImplementedError
          end

          # Lists the installed versions of +gem_name+, constrained by the
          # version spec in +gem_dep+
          #
          # @param gem_dep [Gem::Dependency] the version specification that constrains
          #   which gems are used.
          # @return [Array<Gem::Specification>]  an array of Gem::Specification objects
          #
          def installed_versions(gem_dep)
            rubygems_version = Gem::Version.new(Gem::VERSION)
            if rubygems_version >= Gem::Version.new("2.7")
              # In newer Rubygems, bundler is now a "default gem" which means
              # even with AlternateGemEnvironment when you try to get the
              # installed versions, you get the one from Chef's Ruby's default
              # gems. This workaround ignores default gems entirely so we see
              # only the installed gems.
              stubs = gem_specification.send(:installed_stubs, gem_specification.dirs, "#{gem_dep.name}-*.gemspec")
              # Filter down to only to only stubs we actually want. The name
              # filter is needed in case of things like `foo-*.gemspec` also
              # matching a gem named `foo-bar`.
              stubs.select! { |stub| stub.name == gem_dep.name && gem_dep.requirement.satisfied_by?(stub.version) }
              # This isn't sorting before returning because the only code that
              # uses this method calls `max_by` so it doesn't need to be sorted.
              stubs
            else # >= rubygems 1.8 behavior
              gem_specification.find_all_by_name(gem_dep.name, gem_dep.requirement)
            end
          end

          ##
          # Yields to the provided block with rubygems' source list set to the
          # list provided. Always resets the list when the block returns or
          # raises an exception.
          def with_gem_sources(*sources)
            sources.compact!
            original_sources = Gem.sources
            Gem.sources = sources unless sources.empty?
            yield
          ensure
            Gem.sources = original_sources
          end

          ##
          # Extracts the gemspec from a (on-disk) gem package.
          # === Returns
          # Gem::Specification
          #
          #--
          # Compatibility note: Rubygems 1.x uses Gem::Format, 2.0 moved this
          # code into Gem::Package.
          def spec_from_file(file)
            if defined?(Gem::Format) && Gem::Package.respond_to?(:open)
              Gem::Format.from_file_by_path(file).spec
            else
              Gem::Package.new(file).spec
            end
          end

          ##
          # Determines the candidate version for a gem from a .gem file on disk
          # and checks if it matches the version constraints in +gem_dependency+
          # === Returns
          # Gem::Version  a singular gem version object is returned if the gem
          #               is available
          # nil           returns nil if the gem on disk doesn't match the
          #               version constraints for +gem_dependency+
          def candidate_version_from_file(gem_dependency, source)
            spec = spec_from_file(source)
            if spec.satisfies_requirement?(gem_dependency)
              logger.trace { "found candidate gem version #{spec.version} from local gem package #{source}" }
              spec.version
            else
              # This is probably going to end badly...
              logger.warn { "gem package #{source} does not satisfy the requirements #{gem_dependency}" }
              nil
            end
          end

          ##
          # Finds the newest version that satisfies the constraints of
          # +gem_dependency+. The version is determined from the cache or a
          # round-trip to the server as needed. The architecture and gem
          # sources will be set before making the query.
          # === Returns
          # Gem::Version  a singular gem version object is returned if the gem
          #               is available
          # nil           returns nil if the gem could not be found
          def candidate_version_from_remote(gem_dependency, *sources)
            raise NotImplementedError
          end

          ##
          # Find the newest gem version available from Gem.sources that satisfies
          # the constraints of +gem_dependency+
          def find_newest_remote_version(gem_dependency, *sources)
            spec, source =
              if Chef::Config[:rubygems_cache_enabled]
                # This code caches every gem on rubygems.org and uses lots of RAM
                available_gems = dependency_installer.find_gems_with_sources(gem_dependency)
                available_gems.pick_best!
                best_gem = available_gems.set.first
                best_gem && [best_gem.spec, best_gem.source]
              else
                # Use the API that 'gem install' calls which does not pull down the rubygems universe
                begin
                  rs = dependency_installer.resolve_dependencies gem_dependency.name, gem_dependency.requirement
                  rs.specs.find { |s| s.name == gem_dependency.name }
                  # ruby-3.0.0 versions of rubygems-3.x throws NoMethodError when the dep is not found
                rescue Gem::UnsatisfiableDependencyError, NoMethodError
                  nil
                end
              end

            version = spec && spec.version
            if version
              logger.trace { "found gem #{spec.name} version #{version} for platform #{spec.platform} from #{source}" }
              version
            else
              source_list = sources.compact.empty? ? "[#{Gem.sources.to_a.join(", ")}]" : "[#{sources.join(", ")}]"
              logger.warn { "failed to find gem #{gem_dependency} from #{source_list}" }
              nil
            end
          end

          ##
          # Installs a gem via the rubygems ruby API.
          # === Options
          # :sources    rubygems servers to use
          # Other options are passed to Gem::DependencyInstaller.new
          def install(gem_dependency, options = {})
            with_gem_sources(*options.delete(:sources)) do
              with_correct_verbosity do
                dependency_installer(options).install(gem_dependency)
              end
            end
          end

          ##
          # Uninstall the gem +gem_name+ via the rubygems ruby API. If
          # +gem_version+ is provided, only that version will be uninstalled.
          # Otherwise, all versions are uninstalled.
          # === Options
          # Options are passed to Gem::Uninstaller.new
          def uninstall(gem_name, gem_version = nil, opts = {})
            gem_version ? opts[:version] = gem_version : opts[:all] = true
            with_correct_verbosity do
              uninstaller(gem_name, opts).uninstall
            end
          end

          ##
          # Set rubygems' user interaction to ConsoleUI or SilentUI depending
          # on our current debug level
          def with_correct_verbosity
            Gem::DefaultUserInteraction.ui = logger.trace? ? Gem::ConsoleUI.new : Gem::SilentUI.new
            yield
          end

          def dependency_installer(opts = {})
            Gem::DependencyInstaller.new(opts)
          end

          def uninstaller(gem_name, opts = {})
            Gem::Uninstaller.new(gem_name, DEFAULT_UNINSTALLER_OPTS.merge(opts))
          end

          private

          def logger
            Chef::Log.with_child({ subsystem: "gem_installer_environment" })
          end

        end

        class CurrentGemEnvironment < GemEnvironment

          def gem_paths
            Gem.path
          end

          def gem_source_index
            Gem.source_index
          end

          def gem_specification
            Gem::Specification
          end

          def rubygems_version
            Gem::VERSION
          end

          def candidate_version_from_remote(gem_dependency, *sources)
            with_gem_sources(*sources) do
              find_newest_remote_version(gem_dependency, *sources)
            end
          end

        end

        class AlternateGemEnvironment < GemEnvironment
          JRUBY_PLATFORM = /(:?universal|x86_64|x86)\-java\-[0-9\.]+/.freeze

          def self.gempath_cache
            @gempath_cache ||= {}
          end

          def self.platform_cache
            @platform_cache ||= {}
          end

          include Chef::Mixin::ShellOut

          attr_reader :gem_binary_location

          def initialize(gem_binary_location)
            @gem_binary_location = gem_binary_location
          end

          def rubygems_version
            @rubygems_version ||= shell_out!("#{@gem_binary_location} --version").stdout.chomp
          end

          def gem_paths
            if self.class.gempath_cache.key?(@gem_binary_location)
              self.class.gempath_cache[@gem_binary_location]
            else
              # shellout! is a fork/exec which won't work on windows
              shell_style_paths = shell_out!("#{@gem_binary_location} env gempath").stdout
              # on windows, the path separator is (usually? always?) semicolon
              paths = shell_style_paths.split(::File::PATH_SEPARATOR).map(&:strip)
              self.class.gempath_cache[@gem_binary_location] = paths
            end
          end

          def gem_source_index
            @source_index ||= Gem::SourceIndex.from_gems_in(*gem_paths.map { |p| p + "/specifications" })
          end

          def gem_specification
            # Only once, dirs calls a reset
            unless @specification
              Gem::Specification.dirs = gem_paths
              @specification = Gem::Specification
            end
            @specification
          end

          ##
          # Attempt to detect the correct platform settings for the target gem
          # environment.
          #
          # In practice, this only makes a difference if different versions are
          # available depending on platform, and only if the target gem
          # environment has a radically different platform (i.e., jruby), so we
          # just try to detect jruby and fall back to the current platforms
          # (Gem.platforms) if we don't detect it.
          #
          # === Returns
          # [String|Gem::Platform] returns an array of Gem::Platform-compatible
          # objects, i.e., Strings that are valid for Gem::Platform or actual
          # Gem::Platform objects.
          def gem_platforms
            if self.class.platform_cache.key?(@gem_binary_location)
              self.class.platform_cache[@gem_binary_location]
            else
              gem_environment = shell_out!("#{@gem_binary_location} env").stdout
              self.class.platform_cache[@gem_binary_location] = if jruby = gem_environment[JRUBY_PLATFORM]
                                                                  ["ruby", Gem::Platform.new(jruby)]
                                                                else
                                                                  Gem.platforms
                                                                end
            end
          end

          def with_gem_platforms(*alt_gem_platforms)
            alt_gem_platforms.flatten!
            original_gem_platforms = Gem.platforms
            Gem.platforms = alt_gem_platforms
            yield
          ensure
            Gem.platforms = original_gem_platforms
          end

          def candidate_version_from_remote(gem_dependency, *sources)
            with_gem_sources(*sources) do
              with_gem_platforms(*gem_platforms) do
                find_newest_remote_version(gem_dependency, *sources)
              end
            end
          end

        end

        attr_reader :gem_env
        attr_reader :cleanup_gem_env

        provides :chef_gem
        provides :gem_package

        include Chef::Mixin::GetSourceFromPackage
        include Chef::Mixin::Which

        def initialize(new_resource, run_context = nil)
          super
          @cleanup_gem_env = true
          if new_resource.gem_binary
            if new_resource.options && new_resource.options.is_a?(Hash)
              msg =  "options cannot be given as a hash when using an explicit gem_binary\n"
              msg << "in #{new_resource} from #{new_resource.source_line}"
              raise ArgumentError, msg
            end
            @gem_env = AlternateGemEnvironment.new(new_resource.gem_binary)
            logger.trace("#{new_resource} using gem '#{new_resource.gem_binary}'")
          elsif is_omnibus? && (!new_resource.instance_of? Chef::Resource::ChefGem)
            # Opscode Omnibus - The ruby that ships inside omnibus is only used for Chef
            # Default to installing somewhere more functional
            if new_resource.options && new_resource.options.is_a?(Hash)
              msg = [
                "Gem options must be passed to gem_package as a string instead of a hash when",
                "using this installation of #{ChefUtils::Dist::Infra::PRODUCT} because it runs with its own packaged Ruby. A hash",
                "may only be used when installing a gem to the same Ruby installation that #{ChefUtils::Dist::Infra::PRODUCT} is",
                "running under. See https://docs.chef.io/resources/gem_package/ for more information.",
                "Error raised at #{new_resource} from #{new_resource.source_line}",
              ].join("\n")
              raise ArgumentError, msg
            end
            gem_location = find_gem_by_path
            new_resource.gem_binary gem_location
            @gem_env = AlternateGemEnvironment.new(gem_location)
            logger.trace("#{new_resource} using gem '#{gem_location}'")
          else
            @gem_env = CurrentGemEnvironment.new
            @cleanup_gem_env = false
            logger.trace("#{new_resource} using gem from running ruby environment")
          end
        end

        def is_omnibus?
          if %r{/(opscode|chef|chefdk)/embedded/bin}.match?(RbConfig::CONFIG["bindir"])
            logger.trace("#{new_resource} detected omnibus installation in #{RbConfig::CONFIG["bindir"]}")
            # Omnibus installs to a static path because of linking on unix, find it.
            true
          elsif RbConfig::CONFIG["bindir"].sub(/^\w:/, "") == "/opscode/chef/embedded/bin"
            logger.trace("#{new_resource} detected omnibus installation in #{RbConfig::CONFIG["bindir"]}")
            # windows, with the drive letter removed
            true
          else
            false
          end
        end

        def find_gem_by_path
          which("gem", extra_path: RbConfig::CONFIG["bindir"])
        end

        def gem_dependency
          Gem::Dependency.new(new_resource.package_name, new_resource.version)
        end

        def source_is_remote?
          return true if new_resource.source.nil?
          return true if new_resource.source.is_a?(Array)

          scheme = URI.parse(new_resource.source).scheme
          # URI.parse gets confused by MS Windows paths with forward slashes.
          scheme = nil if /^[a-z]$/.match?(scheme)
          %w{http https}.include?(scheme)
        rescue URI::InvalidURIError
          logger.trace("#{new_resource} failed to parse source '#{new_resource.source}' as a URI, assuming a local path")
          false
        end

        def current_version
          # If one or more matching versions are installed, the newest of them
          # is the current version
          if !matching_installed_versions.empty?
            gemspec = matching_installed_versions.max_by(&:version)
            logger.trace { "#{new_resource} found installed gem #{gemspec.name} version #{gemspec.version} matching #{gem_dependency}" }
            gemspec
            # If no version matching the requirements exists, the latest installed
            # version is the current version.
          elsif !all_installed_versions.empty?
            gemspec = all_installed_versions.max_by(&:version)
            logger.trace { "#{new_resource} newest installed version of gem #{gemspec.name} is #{gemspec.version}" }
            gemspec
          else
            logger.trace { "#{new_resource} no installed version found for #{gem_dependency}" }
            nil
          end
        end

        def matching_installed_versions
          @matching_installed_versions ||= @gem_env.installed_versions(gem_dependency)
        end

        def all_installed_versions
          @all_installed_versions ||= begin
                                        @gem_env.installed_versions(Gem::Dependency.new(gem_dependency.name, ">= 0"))
                                      end
        end

        ##
        # If `include_default_source` is nil, return true if the global
        # `rubygems_url` was set or if `clear_sources` and `source` on the
        # resource are not set.
        # If `include_default_source` is not nil, it has been set explicitly on
        # the resource and that value should be used.
        def include_default_source?
          if new_resource.include_default_source.nil?
            !!Chef::Config[:rubygems_url] || !(new_resource.source || new_resource.clear_sources)
          else
            new_resource.include_default_source
          end
        end

        def gem_sources
          srcs = [ new_resource.source ]
          srcs << (Chef::Config[:rubygems_url] || "https://rubygems.org") if include_default_source?
          srcs.flatten.compact
        end

        def load_current_resource
          @current_resource = Chef::Resource::Package::GemPackage.new(new_resource.name)
          current_resource.package_name(new_resource.package_name)
          if current_spec = current_version
            current_resource.version(current_spec.version.to_s)
          end
          current_resource
        end

        def cleanup_after_converge
          if @cleanup_gem_env
            logger.trace { "#{new_resource} resetting gem environment to default" }
            Gem.clear_paths
          end
        end

        def candidate_version
          @candidate_version ||= begin
                                  if source_is_remote?
                                    @gem_env.candidate_version_from_remote(gem_dependency, *gem_sources).to_s
                                  else
                                    @gem_env.candidate_version_from_file(gem_dependency, new_resource.source).to_s
                                  end
                                end
        end

        def version_requirement_satisfied?(current_version, new_version)
          return false unless current_version && new_version

          Gem::Requirement.new(new_version).satisfied_by?(Gem::Version.new(current_version))
        end

        ##
        # Installs the gem, using either the gems API or shelling out to `gem`
        # according to the following criteria:
        # 1. Use gems API (Gem::DependencyInstaller) by default
        # 2. shell out to `gem install` when a String of options is given
        # 3. use gems API with options if a hash of options is given
        def install_package(name, version)
          if source_is_remote? && new_resource.gem_binary.nil?
            if new_resource.options.nil?
              @gem_env.install(gem_dependency, sources: gem_sources)
            elsif new_resource.options.is_a?(Hash)
              options = new_resource.options
              options[:sources] = gem_sources
              @gem_env.install(gem_dependency, options)
            else
              install_via_gem_command(name, version)
            end
          elsif new_resource.gem_binary.nil?
            @gem_env.install(new_resource.source)
          else
            install_via_gem_command(name, version)
          end
          true
        end

        def gem_binary_path
          new_resource.gem_binary || "gem"
        end

        ##
        # If `clear_sources` is nil, clearing sources is implied if a `source`
        # was added or if the global rubygems URL is set. If `clear_sources`
        # is not nil, it has been set explicitly on the resource and its value
        # should be used.
        def clear_sources?
          if new_resource.clear_sources.nil?
            !!(new_resource.source || Chef::Config[:rubygems_url])
          else
            new_resource.clear_sources
          end
        end

        def install_via_gem_command(name, version)
          src = []
          if new_resource.source.is_a?(String) && new_resource.source =~ /\.gem$/i
            name = new_resource.source
          else
            src << "--clear-sources" if clear_sources?
            src += gem_sources.map { |s| "--source=#{s}" }
          end
          src_str = src.empty? ? "" : " #{src.join(" ")}"
          if !version.nil? && !version.empty?
            shell_out!("#{gem_binary_path} install #{name} -q #{rdoc_string} -v \"#{version}\"#{src_str}#{opts}", env: nil)
          else
            shell_out!("#{gem_binary_path} install \"#{name}\" -q #{rdoc_string} #{src_str}#{opts}", env: nil)
          end
        end

        def upgrade_package(name, version)
          install_package(name, version)
        end

        def remove_package(name, version)
          if new_resource.gem_binary.nil?
            if new_resource.options.nil?
              @gem_env.uninstall(name, version)
            elsif new_resource.options.is_a?(Hash)
              @gem_env.uninstall(name, version, new_resource.options)
            else
              uninstall_via_gem_command(name, version)
            end
          else
            uninstall_via_gem_command(name, version)
          end
        end

        def uninstall_via_gem_command(name, version)
          if version
            shell_out!("#{gem_binary_path} uninstall #{name} -q -x -I -v \"#{version}\"#{opts}", env: nil)
          else
            shell_out!("#{gem_binary_path} uninstall #{name} -q -x -I -a#{opts}", env: nil)
          end
        end

        def purge_package(name, version)
          remove_package(name, version)
        end

        private

        def rdoc_string
          if needs_nodocument?
            "--no-document"
          else
            "--no-rdoc --no-ri"
          end
        end

        def needs_nodocument?
          Gem::Requirement.new(">= 3.0.0.beta1").satisfied_by?(Gem::Version.new(gem_env.rubygems_version))
        end

        def opts
          expand_options(new_resource.options)
        end

      end
    end
  end
end