require 'singleton' module Kontena class PluginManager include Singleton CLI_GEM = 'kontena-cli'.freeze MIN_CLI_VERSION = '0.15.99'.freeze # Initialize plugin manager def init ENV["GEM_HOME"] = install_dir Gem.paths = ENV plugins use_dummy_ui unless ENV["DEBUG"] true end # Install a plugin # @param plugin_name [String] # @param pre [Boolean] install a prerelease version if available # @param version [String] install a specific version def install_plugin(plugin_name, pre: false, version: nil) require 'rubygems/dependency_installer' require 'rubygems/requirement' cmd = Gem::DependencyInstaller.new( document: false, force: true, prerelease: pre, minimal_deps: true ) plugin_version = version.nil? ? Gem::Requirement.default : Gem::Requirement.new(version) without_safe { cmd.install(prefix(plugin_name), plugin_version) } cmd.installed_gems end # Uninstall a plugin # @param plugin_name [String] def uninstall_plugin(plugin_name) installed = installed(plugin_name) raise "Plugin #{plugin_name} not installed" unless installed require 'rubygems/uninstaller' cmd = Gem::Uninstaller.new( installed.name, all: true, executables: true, force: true, install_dir: installed.base_dir ) cmd.uninstall end # Search rubygems for kontena plugins # @param pattern [String] optional search pattern def search_plugins(pattern = nil) client = Excon.new('https://rubygems.org') response = client.get( path: "/api/v1/search.json?query=#{prefix(pattern)}", headers: { 'Content-Type' => 'application/json', 'Accept' => 'application/json' } ) JSON.parse(response.body) rescue nil end # Retrieve plugin versions from rubygems # @param plugin_name [String] def gem_versions(plugin_name) client = Excon.new('https://rubygems.org') response = client.get( path: "/api/v1/versions/#{prefix(plugin_name)}.json", headers: { 'Content-Type' => 'application/json', 'Accept' => 'application/json' } ) versions = JSON.parse(response.body) versions.map { |version| Gem::Version.new(version["number"]) }.sort.reverse end # Get the latest version number from rubygems # @param plugin_name [String] # @param pre [Boolean] include prerelease versions def latest_version(plugin_name, pre: false) return gem_versions(plugin_name).first if pre gem_versions(plugin_name).find { |version| !version.prerelease? } end # Find a plugin by name from installed plugins # @param plugin_name [String] def installed(plugin_name) search = prefix(plugin_name) plugins.find {|plugin| plugin.name == search } end # Upgrade an installed plugin # @param plugin_name [String] # @param pre [Boolean] upgrade to a prerelease version if available. Will happen always when the installed version is a prerelease version. def upgrade_plugin(plugin_name, pre: false) installed = installed(plugin_name) if installed.version.prerelease? pre = true end if installed latest = latest_version(plugin_name, pre: pre) if latest > installed.version install_plugin(plugin_name, version: latest.to_s) end else raise "Plugin #{plugin_name} not installed" end end # Runs gem cleanup, removes remains from previous versions # @param plugin_name [String] def cleanup_plugin(plugin_name) require 'rubygems/commands/cleanup_command' cmd = Gem::Commands::CleanupCommand.new options = [] options += ['-q', '--no-verbose'] unless ENV["DEBUG"] cmd.handle_options options without_safe { cmd.execute } rescue Gem::SystemExitException => e return true if e.exit_code == 0 raise end # Gem installation directory # @return [String] def install_dir return @install_dir if @install_dir install_dir = File.join(Dir.home, '.kontena', 'gems', RUBY_VERSION) unless File.directory?(install_dir) require 'fileutils' FileUtils.mkdir_p(install_dir, mode: 0700) end @install_dir = install_dir end # @return [Array] def plugins @plugins ||= load_plugins end private # Execute block without SafeYAML. Gem does security internally. def without_safe(&block) SafeYAML::OPTIONS[:default_mode] = :unsafe if Object.const_defined?(:SafeYAML) yield ensure SafeYAML::OPTIONS[:default_mode] = :safe if Object.const_defined?(:SafeYAML) end def load_plugins plugins = [] Gem::Specification.to_a.each do |spec| spec.require_paths.to_a.each do |require_path| plugin = File.join(spec.gem_dir, require_path, 'kontena_cli_plugin.rb') if File.exist?(plugin) && !plugins.find{ |p| p.name == spec.name } begin if spec_has_valid_dependency?(spec) load(plugin) plugins << spec else plugin_name = spec.name.sub('kontena-plugin-', '') STDERR.puts " [#{Kontena.pastel.red('error')}] Plugin #{Kontena.pastel.cyan(plugin_name)} (#{spec.version}) is not compatible with the current cli version." STDERR.puts " To update the plugin, run 'kontena plugin install #{plugin_name}'" end rescue LoadError => exc STDERR.puts " [#{Kontena.pastel.red('error')}] Failed to load plugin: #{spec.name}" if ENV['DEBUG'] STDERR.puts exc.message STDERR.puts exc.backtrace.join("\n") end exit 1 end end end end plugins rescue => exc STDERR.puts exc.message end def prefix(plugin_name) return plugin_name if plugin_name.to_s.start_with?('kontena-plugin-') "kontena-plugin-#{plugin_name}" end def dummy_ui Gem::StreamUI.new(StringIO.new, StringIO.new, StringIO.new, false) end def use_dummy_ui require 'rubygems/user_interaction' Gem::DefaultUserInteraction.ui = dummy_ui end # @param [Gem::Specification] spec # @return [Boolean] def spec_has_valid_dependency?(spec) kontena_cli = spec.runtime_dependencies.find{ |d| d.name == CLI_GEM } !kontena_cli.match?(CLI_GEM, MIN_CLI_VERSION) rescue false end end end