diff options
author | Seth Chisamore <schisamo@opscode.com> | 2012-10-30 10:39:35 -0400 |
---|---|---|
committer | Seth Chisamore <schisamo@opscode.com> | 2012-10-30 10:39:35 -0400 |
commit | 24dc69a9a97e82a6e4207de68d6dcc664178249b (patch) | |
tree | 19bb289c9f88b4bbab066bc56b95d6d222fd5c35 /lib/chef/knife.rb | |
parent | 9348c1c9c80ee757354d624b7dc1b78ebc7605c4 (diff) | |
download | chef-24dc69a9a97e82a6e4207de68d6dcc664178249b.tar.gz |
[OC-3564] move core Chef to the repo root \o/ \m/
The opscode/chef repository now only contains the core Chef library code
used by chef-client, knife and chef-solo!
Diffstat (limited to 'lib/chef/knife.rb')
-rw-r--r-- | lib/chef/knife.rb | 537 |
1 files changed, 537 insertions, 0 deletions
diff --git a/lib/chef/knife.rb b/lib/chef/knife.rb new file mode 100644 index 0000000000..35aedd2cb3 --- /dev/null +++ b/lib/chef/knife.rb @@ -0,0 +1,537 @@ +# +# Author:: Adam Jacob (<adam@opscode.com>) +# Author:: Christopher Brown (<cb@opscode.com>) +# Copyright:: Copyright (c) 2009 Opscode, 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. +# + +require 'forwardable' +require 'chef/version' +require 'mixlib/cli' +require 'chef/mixin/convert_to_class_name' +require 'chef/mixin/path_sanity' +require 'chef/knife/core/subcommand_loader' +require 'chef/knife/core/ui' +require 'chef/rest' +require 'pp' + +class Chef + class Knife + + Chef::REST::RESTRequest.user_agent = "Chef Knife#{Chef::REST::RESTRequest::UA_COMMON}" + + include Mixlib::CLI + include Chef::Mixin::PathSanity + extend Chef::Mixin::ConvertToClassName + extend Forwardable + + # Backwards Compat: + # Ideally, we should not vomit all of these methods into this base class; + # instead, they should be accessed by hitting the ui object directly. + def_delegator :@ui, :stdout + def_delegator :@ui, :stderr + def_delegator :@ui, :stdin + def_delegator :@ui, :msg + def_delegator :@ui, :ask_question + def_delegator :@ui, :pretty_print + def_delegator :@ui, :output + def_delegator :@ui, :format_list_for_display + def_delegator :@ui, :format_for_display + def_delegator :@ui, :format_cookbook_list_for_display + def_delegator :@ui, :edit_data + def_delegator :@ui, :edit_object + def_delegator :@ui, :confirm + + attr_accessor :name_args + attr_accessor :ui + + def self.ui + @ui ||= Chef::Knife::UI.new(STDOUT, STDERR, STDIN, {}) + end + + def self.msg(msg="") + ui.msg(msg) + end + + def self.reset_subcommands! + @@subcommands = {} + @subcommands_by_category = nil + end + + def self.inherited(subclass) + unless subclass.unnamed? + subcommands[subclass.snake_case_name] = subclass + end + end + + # Explicitly set the category for the current command to +new_category+ + # The category is normally determined from the first word of the command + # name, but some commands make more sense using two or more words + # ===Arguments + # new_category::: A String to set the category to (see examples) + # ===Examples: + # Data bag commands would be in the 'data' category by default. To put them + # in the 'data bag' category: + # category('data bag') + def self.category(new_category) + @category = new_category + end + + def self.subcommand_category + @category || snake_case_name.split('_').first unless unnamed? + end + + def self.snake_case_name + convert_to_snake_case(name.split('::').last) unless unnamed? + end + + def self.common_name + snake_case_name.split('_').join(' ') + end + + # Does this class have a name? (Classes created via Class.new don't) + def self.unnamed? + name.nil? || name.empty? + end + + def self.subcommand_loader + @subcommand_loader ||= Knife::SubcommandLoader.new(chef_config_dir) + end + + def self.load_commands + @commands_loaded ||= subcommand_loader.load_commands + end + + def self.subcommands + @@subcommands ||= {} + end + + def self.subcommands_by_category + unless @subcommands_by_category + @subcommands_by_category = Hash.new { |hash, key| hash[key] = [] } + subcommands.each do |snake_cased, klass| + @subcommands_by_category[klass.subcommand_category] << snake_cased + end + end + @subcommands_by_category + end + + # Print the list of subcommands knife knows about. If +preferred_category+ + # is given, only subcommands in that category are shown + def self.list_commands(preferred_category=nil) + load_commands + + category_desc = preferred_category ? preferred_category + " " : '' + msg "Available #{category_desc}subcommands: (for details, knife SUB-COMMAND --help)\n\n" + + if preferred_category && subcommands_by_category.key?(preferred_category) + commands_to_show = {preferred_category => subcommands_by_category[preferred_category]} + else + commands_to_show = subcommands_by_category + end + + commands_to_show.sort.each do |category, commands| + next if category =~ /deprecated/i + msg "** #{category.upcase} COMMANDS **" + commands.each do |command| + msg subcommands[command].banner if subcommands[command] + end + msg + end + end + + # Run knife for the given +args+ (ARGV), adding +options+ to the list of + # CLI options that the subcommand knows how to handle. + # ===Arguments + # args::: usually ARGV + # options::: A Mixlib::CLI option parser hash. These +options+ are how + # subcommands know about global knife CLI options + def self.run(args, options={}) + load_commands + subcommand_class = subcommand_class_from(args) + subcommand_class.options = options.merge!(subcommand_class.options) + subcommand_class.load_deps + instance = subcommand_class.new(args) + instance.configure_chef + instance.run_with_pretty_exceptions + end + + def self.guess_category(args) + category_words = args.select {|arg| arg =~ /^(([[:alnum:]])[[:alnum:]\_\-]+)$/ } + category_words.map! {|w| w.split('-')}.flatten! + matching_category = nil + while (!matching_category) && (!category_words.empty?) + candidate_category = category_words.join(' ') + matching_category = candidate_category if subcommands_by_category.key?(candidate_category) + matching_category || category_words.pop + end + matching_category + end + + def self.subcommand_class_from(args) + command_words = args.select {|arg| arg =~ /^(([[:alnum:]])[[:alnum:]\_\-]+)$/ } + + subcommand_class = nil + + while ( !subcommand_class ) && ( !command_words.empty? ) + snake_case_class_name = command_words.join("_") + unless subcommand_class = subcommands[snake_case_class_name] + command_words.pop + end + end + # see if we got the command as e.g., knife node-list + subcommand_class ||= subcommands[args.first.gsub('-', '_')] + subcommand_class || subcommand_not_found!(args) + end + + def self.deps(&block) + @dependency_loader = block + end + + def self.load_deps + @dependency_loader && @dependency_loader.call + end + + private + + OFFICIAL_PLUGINS = %w[ec2 rackspace windows openstack terremark bluebox] + + # :nodoc: + # Error out and print usage. probably becuase the arguments given by the + # user could not be resolved to a subcommand. + def self.subcommand_not_found!(args) + ui.fatal("Cannot find sub command for: '#{args.join(' ')}'") + + if category_commands = guess_category(args) + list_commands(category_commands) + elsif missing_plugin = ( OFFICIAL_PLUGINS.find {|plugin| plugin == args[0]} ) + ui.info("The #{missing_plugin} commands were moved to plugins in Chef 0.10") + ui.info("You can install the plugin with `(sudo) gem install knife-#{missing_plugin}") + else + list_commands + end + + exit 10 + end + + @@chef_config_dir = nil + + # search upward from current_dir until .chef directory is found + def self.chef_config_dir + if @@chef_config_dir.nil? # share this with subclasses + @@chef_config_dir = false + full_path = Dir.pwd.split(File::SEPARATOR) + (full_path.length - 1).downto(0) do |i| + candidate_directory = File.join(full_path[0..i] + [".chef" ]) + if File.exist?(candidate_directory) && File.directory?(candidate_directory) + @@chef_config_dir = candidate_directory + break + end + end + end + @@chef_config_dir + end + + + public + + # Create a new instance of the current class configured for the given + # arguments and options + def initialize(argv=[]) + super() # having to call super in initialize is the most annoying anti-pattern :( + @ui = Chef::Knife::UI.new(STDOUT, STDERR, STDIN, config) + + command_name_words = self.class.snake_case_name.split('_') + + # Mixlib::CLI ignores the embedded name_args + @name_args = parse_options(argv) + @name_args.delete(command_name_words.join('-')) + @name_args.reject! { |name_arg| command_name_words.delete(name_arg) } + + # knife node run_list add requires that we have extra logic to handle + # the case that command name words could be joined by an underscore :/ + command_name_words = command_name_words.join('_') + @name_args.reject! { |name_arg| command_name_words == name_arg } + + if config[:help] + msg opt_parser + exit 1 + end + + # copy Mixlib::CLI over so that it cab be configured in knife.rb + # config file + Chef::Config[:verbosity] = config[:verbosity] + end + + def parse_options(args) + super + rescue OptionParser::InvalidOption => e + puts "Error: " + e.to_s + show_usage + exit(1) + end + + def configure_chef + unless config[:config_file] + candidate_configs = [] + + # Look for $KNIFE_HOME/knife.rb (allow multiple knives config on same machine) + if ENV['KNIFE_HOME'] + candidate_configs << File.join(ENV['KNIFE_HOME'], 'knife.rb') + end + # Look for $PWD/knife.rb + if Dir.pwd + candidate_configs << File.join(Dir.pwd, 'knife.rb') + end + # Look for $UPWARD/.chef/knife.rb + if self.class.chef_config_dir + candidate_configs << File.join(self.class.chef_config_dir, 'knife.rb') + end + # Look for $HOME/.chef/knife.rb + if ENV['HOME'] + candidate_configs << File.join(ENV['HOME'], '.chef', 'knife.rb') + end + + candidate_configs.each do | candidate_config | + candidate_config = File.expand_path(candidate_config) + if File.exist?(candidate_config) + config[:config_file] = candidate_config + break + end + end + end + + # Don't try to load a knife.rb if it doesn't exist. + if config[:config_file] + read_config_file(config[:config_file]) + else + # ...but do log a message if no config was found. + Chef::Config[:color] = config[:color] + ui.warn("No knife configuration file found") + end + + Chef::Config[:color] = config[:color] + + case Chef::Config[:verbosity] + when 0 + Chef::Config[:log_level] = :error + when 1 + Chef::Config[:log_level] = :info + else + Chef::Config[:log_level] = :debug + end + + Chef::Config[:node_name] = config[:node_name] if config[:node_name] + Chef::Config[:client_key] = config[:client_key] if config[:client_key] + Chef::Config[:chef_server_url] = config[:chef_server_url] if config[:chef_server_url] + Chef::Config[:environment] = config[:environment] if config[:environment] + + # Expand a relative path from the config directory. Config from command + # line should already be expanded, and absolute paths will be unchanged. + if Chef::Config[:client_key] && config[:config_file] + Chef::Config[:client_key] = File.expand_path(Chef::Config[:client_key], File.dirname(config[:config_file])) + end + + Mixlib::Log::Formatter.show_time = false + Chef::Log.init(Chef::Config[:log_location]) + Chef::Log.level(Chef::Config[:log_level] || :error) + + Chef::Log.debug("Using configuration from #{config[:config_file]}") + + if Chef::Config[:node_name] && Chef::Config[:node_name].bytesize > 90 + # node names > 90 bytes only work with authentication protocol >= 1.1 + # see discussion in config.rb. + Chef::Config[:authentication_protocol_version] = "1.1" + end + end + + def read_config_file(file) + Chef::Config.from_file(file) + rescue SyntaxError => e + ui.error "You have invalid ruby syntax in your config file #{file}" + ui.info(ui.color(e.message, :red)) + if file_line = e.message[/#{Regexp.escape(file)}:[\d]+/] + line = file_line[/:([\d]+)$/, 1].to_i + highlight_config_error(file, line) + end + exit 1 + rescue Exception => e + ui.error "You have an error in your config file #{file}" + ui.info "#{e.class.name}: #{e.message}" + filtered_trace = e.backtrace.grep(/#{Regexp.escape(file)}/) + filtered_trace.each {|line| ui.msg(" " + ui.color(line, :red))} + if !filtered_trace.empty? + line_nr = filtered_trace.first[/#{Regexp.escape(file)}:([\d]+)/, 1] + highlight_config_error(file, line_nr.to_i) + end + + exit 1 + end + + def highlight_config_error(file, line) + config_file_lines = [] + IO.readlines(file).each_with_index {|l, i| config_file_lines << "#{(i + 1).to_s.rjust(3)}: #{l.chomp}"} + if line == 1 + lines = config_file_lines[0..3] + lines[0] = ui.color(lines[0], :red) + else + lines = config_file_lines[Range.new(line - 2, line)] + lines[1] = ui.color(lines[1], :red) + end + ui.msg "" + ui.msg ui.color(" # #{file}", :white) + lines.each {|l| ui.msg(l)} + ui.msg "" + end + + def show_usage + stdout.puts("USAGE: " + self.opt_parser.to_s) + end + + def run_with_pretty_exceptions + unless self.respond_to?(:run) + ui.error "You need to add a #run method to your knife command before you can use it" + end + enforce_path_sanity + run + rescue Exception => e + raise if Chef::Config[:verbosity] == 2 + humanize_exception(e) + exit 100 + end + + def humanize_exception(e) + case e + when SystemExit + raise # make sure exit passes through. + when Net::HTTPServerException, Net::HTTPFatalError + humanize_http_exception(e) + when Errno::ECONNREFUSED, Timeout::Error, Errno::ETIMEDOUT, SocketError + ui.error "Network Error: #{e.message}" + ui.info "Check your knife configuration and network settings" + when NameError, NoMethodError + ui.error "knife encountered an unexpected error" + ui.info "This may be a bug in the '#{self.class.common_name}' knife command or plugin" + ui.info "Please collect the output of this command with the `-VV` option before filing a bug report." + ui.info "Exception: #{e.class.name}: #{e.message}" + when Chef::Exceptions::PrivateKeyMissing + ui.error "Your private key could not be loaded from #{api_key}" + ui.info "Check your configuration file and ensure that your private key is readable" + else + ui.error "#{e.class.name}: #{e.message}" + end + end + + def humanize_http_exception(e) + response = e.response + case response + when Net::HTTPUnauthorized + ui.error "Failed to authenticate to #{server_url} as #{username} with key #{api_key}" + ui.info "Response: #{format_rest_error(response)}" + when Net::HTTPForbidden + ui.error "You authenticated successfully to #{server_url} as #{username} but you are not authorized for this action" + ui.info "Response: #{format_rest_error(response)}" + when Net::HTTPBadRequest + ui.error "The data in your request was invalid" + ui.info "Response: #{format_rest_error(response)}" + when Net::HTTPNotFound + ui.error "The object you are looking for could not be found" + ui.info "Response: #{format_rest_error(response)}" + when Net::HTTPInternalServerError + ui.error "internal server error" + ui.info "Response: #{format_rest_error(response)}" + when Net::HTTPBadGateway + ui.error "bad gateway" + ui.info "Response: #{format_rest_error(response)}" + when Net::HTTPServiceUnavailable + ui.error "Service temporarily unavailable" + ui.info "Response: #{format_rest_error(response)}" + else + ui.error response.message + ui.info "Response: #{format_rest_error(response)}" + end + end + + def username + Chef::Config[:node_name] + end + + def api_key + Chef::Config[:client_key] + end + + # Parses JSON from the error response sent by Chef Server and returns the + # error message + #-- + # TODO: this code belongs in Chef::REST + def format_rest_error(response) + Array(Chef::JSONCompat.from_json(response.body)["error"]).join('; ') + rescue Exception + response.body + end + + def create_object(object, pretty_name=nil, &block) + output = edit_data(object) + + if Kernel.block_given? + output = block.call(output) + else + output.save + end + + pretty_name ||= output + + self.msg("Created #{pretty_name}") + + output(output) if config[:print_after] + end + + def delete_object(klass, name, delete_name=nil, &block) + confirm("Do you really want to delete #{name}") + + if Kernel.block_given? + object = block.call + else + object = klass.load(name) + object.destroy + end + + output(format_for_display(object)) if config[:print_after] + + obj_name = delete_name ? "#{delete_name}[#{name}]" : object + self.msg("Deleted #{obj_name}") + end + + def rest + @rest ||= begin + require 'chef/rest' + Chef::REST.new(Chef::Config[:chef_server_url]) + end + end + + def noauth_rest + @rest ||= begin + require 'chef/rest' + Chef::REST.new(Chef::Config[:chef_server_url], false, false) + end + end + + def server_url + Chef::Config[:chef_server_url] + end + + end +end + |