diff options
Diffstat (limited to 'lib/chef/knife/core')
-rw-r--r-- | lib/chef/knife/core/bootstrap_context.rb | 106 | ||||
-rw-r--r-- | lib/chef/knife/core/cookbook_scm_repo.rb | 160 | ||||
-rw-r--r-- | lib/chef/knife/core/generic_presenter.rb | 204 | ||||
-rw-r--r-- | lib/chef/knife/core/node_editor.rb | 130 | ||||
-rw-r--r-- | lib/chef/knife/core/node_presenter.rb | 137 | ||||
-rw-r--r-- | lib/chef/knife/core/object_loader.rb | 112 | ||||
-rw-r--r-- | lib/chef/knife/core/subcommand_loader.rb | 112 | ||||
-rw-r--r-- | lib/chef/knife/core/text_formatter.rb | 86 | ||||
-rw-r--r-- | lib/chef/knife/core/ui.rb | 219 |
9 files changed, 1266 insertions, 0 deletions
diff --git a/lib/chef/knife/core/bootstrap_context.rb b/lib/chef/knife/core/bootstrap_context.rb new file mode 100644 index 0000000000..71dc008d39 --- /dev/null +++ b/lib/chef/knife/core/bootstrap_context.rb @@ -0,0 +1,106 @@ +# +# Author:: Daniel DeLeo (<dan@opscode.com>) +# Copyright:: Copyright (c) 2011 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 'chef/run_list' +class Chef + class Knife + module Core + # Instances of BootstrapContext are the context objects (i.e., +self+) for + # bootstrap templates. For backwards compatability, they +must+ set the + # following instance variables: + # * @config - a hash of knife's config values + # * @run_list - the run list for the node to boostrap + # + class BootstrapContext + + def initialize(config, run_list, chef_config) + @config = config + @run_list = run_list + @chef_config = chef_config + end + + def bootstrap_version_string + if @config[:prerelease] + "--prerelease" + else + "--version #{chef_version}" + end + end + + def bootstrap_environment + @chef_config[:environment] || '_default' + end + + def validation_key + IO.read(@chef_config[:validation_key]) + end + + def encrypted_data_bag_secret + IO.read(@chef_config[:encrypted_data_bag_secret]) + end + + def config_content + client_rb = <<-CONFIG +log_level :info +log_location STDOUT +chef_server_url "#{@chef_config[:chef_server_url]}" +validation_client_name "#{@chef_config[:validation_client_name]}" +CONFIG + if @config[:chef_node_name] + client_rb << %Q{node_name "#{@config[:chef_node_name]}"\n} + else + client_rb << "# Using default node name (fqdn)\n" + end + + if knife_config[:bootstrap_proxy] + client_rb << %Q{http_proxy "#{knife_config[:bootstrap_proxy]}"\n} + client_rb << %Q{https_proxy "#{knife_config[:bootstrap_proxy]}"\n} + end + + if @chef_config[:encrypted_data_bag_secret] + client_rb << %Q{encrypted_data_bag_secret "/etc/chef/encrypted_data_bag_secret"\n} + end + + client_rb + end + + def start_chef + # If the user doesn't have a client path configure, let bash use the PATH for what it was designed for + client_path = @chef_config[:chef_client_path] || 'chef-client' + s = "#{client_path} -j /etc/chef/first-boot.json" + s << " -E #{bootstrap_environment}" if chef_version.to_f != 0.9 # only use the -E option on Chef 0.10+ + s + end + + def knife_config + @chef_config.key?(:knife) ? @chef_config[:knife] : {} + end + + def chef_version + knife_config[:bootstrap_version] || Chef::VERSION + end + + def first_boot + (@config[:first_boot_attributes] || {}).merge(:run_list => @run_list) + end + + end + end + end +end + diff --git a/lib/chef/knife/core/cookbook_scm_repo.rb b/lib/chef/knife/core/cookbook_scm_repo.rb new file mode 100644 index 0000000000..727cff3153 --- /dev/null +++ b/lib/chef/knife/core/cookbook_scm_repo.rb @@ -0,0 +1,160 @@ +# +# Author:: Daniel DeLeo (<dan@opscode.com>) +# Copyright:: Copyright (c) 2011 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 'chef/mixin/shell_out' + +class Chef + class Knife + class CookbookSCMRepo + + DIRTY_REPO = /^[\s]+M/ + + include Chef::Mixin::ShellOut + + attr_reader :repo_path + attr_reader :default_branch + attr_reader :use_current_branch + attr_reader :ui + + def initialize(repo_path, ui, opts={}) + @repo_path = repo_path + @ui = ui + @default_branch = 'master' + @use_current_branch = false + apply_opts(opts) + end + + def sanity_check + unless ::File.directory?(repo_path) + ui.error("The cookbook repo path #{repo_path} does not exist or is not a directory") + exit 1 + end + unless git_repo?(repo_path) + ui.error "The cookbook repo #{repo_path} is not a git repository." + ui.info("Use `git init` to initialize a git repo") + exit 1 + end + if use_current_branch + @default_branch = get_current_branch() + end + unless branch_exists?(default_branch) + ui.error "The default branch '#{default_branch}' does not exist" + ui.info "If this is a new git repo, make sure you have at least one commit before installing cookbooks" + exit 1 + end + cmd = git('status --porcelain') + if cmd.stdout =~ DIRTY_REPO + ui.error "You have uncommitted changes to your cookbook repo (#{repo_path}):" + ui.msg cmd.stdout + ui.info "Commit or stash your changes before importing cookbooks" + exit 1 + end + # TODO: any untracked files in the cookbook directory will get nuked later + # make this an error condition also. + true + end + + def reset_to_default_state + ui.info("Checking out the #{default_branch} branch.") + git("checkout #{default_branch}") + end + + def prepare_to_import(cookbook_name) + branch = "chef-vendor-#{cookbook_name}" + if branch_exists?(branch) + ui.info("Pristine copy branch (#{branch}) exists, switching to it.") + git("checkout #{branch}") + else + ui.info("Creating pristine copy branch #{branch}") + git("checkout -b #{branch}") + end + end + + def finalize_updates_to(cookbook_name, version) + if update_count = updated?(cookbook_name) + ui.info "#{update_count} files updated, committing changes" + git("add #{cookbook_name}") + git("commit -m \"Import #{cookbook_name} version #{version}\" -- #{cookbook_name}") + ui.info("Creating tag cookbook-site-imported-#{cookbook_name}-#{version}") + git("tag -f cookbook-site-imported-#{cookbook_name}-#{version}") + true + else + ui.info("No changes made to #{cookbook_name}") + false + end + end + + def merge_updates_from(cookbook_name, version) + branch = "chef-vendor-#{cookbook_name}" + Dir.chdir(repo_path) do + if system("git merge #{branch}") + ui.info("Cookbook #{cookbook_name} version #{version} successfully installed") + else + ui.error("You have merge conflicts - please resolve manually") + ui.info("Merge status (cd #{repo_path}; git status):") + system("git status") + exit 3 + end + end + end + + def updated?(cookbook_name) + update_count = git("status --porcelain -- #{cookbook_name}").stdout.strip.lines.count + update_count == 0 ? nil : update_count + end + + def branch_exists?(branch_name) + git("branch --no-color").stdout.lines.any? {|l| l =~ /\s#{Regexp.escape(branch_name)}(?:\s|$)/ } + end + + def get_current_branch() + ref = git("symbolic-ref HEAD").stdout + ref.chomp.split('/')[2] + end + + private + + def git_repo?(directory) + if File.directory?(File.join(directory, '.git')) + return true + elsif File.dirname(directory) == directory + return false + else + git_repo?(File.dirname(directory)) + end + end + + def apply_opts(opts) + opts.each do |option, value| + case option.to_s + when 'default_branch' + @default_branch = value + when 'use_current_branch' + @use_current_branch = value + end + end + end + + def git(command) + shell_out!("git #{command}", :cwd => repo_path) + end + + end + end +end + diff --git a/lib/chef/knife/core/generic_presenter.rb b/lib/chef/knife/core/generic_presenter.rb new file mode 100644 index 0000000000..0866f10147 --- /dev/null +++ b/lib/chef/knife/core/generic_presenter.rb @@ -0,0 +1,204 @@ +#-- +# Author:: Daniel DeLeo (<dan@opscode.com>) +# Copyright:: Copyright (c) 2011 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 'chef/knife/core/text_formatter' + +class Chef + class Knife + module Core + + #==Chef::Knife::Core::GenericPresenter + # The base presenter class for displaying structured data in knife commands. + # This is not an abstract base class, and it is suitable for displaying + # most kinds of objects that knife needs to display. + class GenericPresenter + + attr_reader :ui + attr_reader :config + + # Instaniates a new GenericPresenter. This is generally handled by the + # Chef::Knife::UI object, though you need to match the signature of this + # method if you intend to use your own presenter instead. + def initialize(ui, config) + @ui, @config = ui, config + end + + # Is the selected output format a data interchange format? + # Returns true if the selected output format is json or yaml, false + # otherwise. Knife search uses this to adjust its data output so as not + # to produce invalid JSON output. + def interchange? + case parse_format_option + when :json, :yaml + true + else + false + end + end + + # Returns a String representation of +data+ that is suitable for output + # to a terminal or perhaps for data interchange with another program. + # The representation of the +data+ depends on the value of the + # `config[:format]` setting. + def format(data) + case parse_format_option + when :summary + summarize(data) + when :text + text_format(data) + when :json + Chef::JSONCompat.to_json_pretty(data) + when :yaml + require 'yaml' + YAML::dump(data) + when :pp + require 'stringio' + # If you were looking for some attribute and there is only one match + # just dump the attribute value + if config[:attribute] and data.length == 1 + data.values[0] + else + out = StringIO.new + PP.pp(data, out) + out.string + end + end + end + + # Converts the user-supplied value of `config[:format]` to a Symbol + # representing the desired output format. + # ===Returns + # returns one of :summary, :text, :json, :yaml, or :pp + # ===Raises + # Raises an ArgumentError if the desired output format could not be + # determined from the value of `config[:format]` + def parse_format_option + case config[:format] + when "summary", /^s/, nil + :summary + when "text", /^t/ + :text + when "json", /^j/ + :json + when "yaml", /^y/ + :yaml + when "pp", /^p/ + :pp + else + raise ArgumentError, "Unknown output format #{config[:format]}" + end + end + + # Summarize the data. Defaults to text format output, + # which may not be very summary-like + def summarize(data) + text_format(data) + end + + # Converts the +data+ to a String in the text format. Uses + # Chef::Knife::Core::TextFormatter + def text_format(data) + TextFormatter.new(data, ui).formatted_data + end + + def format_list_for_display(list) + config[:with_uri] ? list : list.keys.sort { |a,b| a <=> b } + end + + def format_for_display(data) + if formatting_subset_of_data? + format_data_subset_for_display(data) + elsif config[:id_only] + name_or_id_for(data) + elsif config[:environment] && data.respond_to?(:chef_environment) + {"chef_environment" => data.chef_environment} + else + data + end + end + + def format_data_subset_for_display(data) + subset = if config[:attribute] + result = {} + Array(config[:attribute]).each do |nested_value_spec| + nested_value = extract_nested_value(data, nested_value_spec) + result[nested_value_spec] = nested_value + end + result + elsif config[:run_list] + run_list = data.run_list.run_list + { "run_list" => run_list } + else + raise ArgumentError, "format_data_subset_for_display requires attribute, run_list, or id_only config option to be set" + end + {name_or_id_for(data) => subset } + end + + def name_or_id_for(data) + data.respond_to?(:name) ? data.name : data["id"] + end + + def formatting_subset_of_data? + config[:attribute] || config[:run_list] + end + + + def extract_nested_value(data, nested_value_spec) + nested_value_spec.split(".").each do |attr| + if data.nil? + nil # don't get no method error on nil + elsif data.respond_to?(attr.to_sym) + data = data.send(attr.to_sym) + elsif data.respond_to?(:[]) + data = data[attr] + else + data = begin + data.send(attr.to_sym) + rescue NoMethodError + nil + end + end + end + ( !data.kind_of?(Array) && data.respond_to?(:to_hash) ) ? data.to_hash : data + end + + def format_cookbook_list_for_display(item) + if config[:with_uri] + item.inject({}) do |collected, (cookbook, versions)| + collected[cookbook] = Hash.new + versions['versions'].each do |ver| + collected[cookbook][ver['version']] = ver['url'] + end + collected + end + else + versions_by_cookbook = item.inject({}) do |collected, ( cookbook, versions )| + collected[cookbook] = versions["versions"].map {|v| v['version']} + collected + end + key_length = versions_by_cookbook.empty? ? 0 : versions_by_cookbook.keys.map {|name| name.size }.max + 2 + versions_by_cookbook.sort.map do |cookbook, versions| + "#{cookbook.ljust(key_length)} #{versions.join(' ')}" + end + end + end + + end + end + end +end diff --git a/lib/chef/knife/core/node_editor.rb b/lib/chef/knife/core/node_editor.rb new file mode 100644 index 0000000000..22ba3eaa25 --- /dev/null +++ b/lib/chef/knife/core/node_editor.rb @@ -0,0 +1,130 @@ +# +# Author:: Daniel DeLeo (<dan@opscode.com>) +# Copyright:: Copyright (c) 2011 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 'chef/json_compat' +require 'chef/node' + +class Chef + class Knife + class NodeEditor + + attr_reader :node + attr_reader :ui + attr_reader :config + + def initialize(node, ui, config) + @node, @ui, @config = node, ui, config + end + + def edit_node + abort "You specified the --disable_editing option, nothing to edit" if config[:disable_editing] + assert_editor_set! + + updated_node_data = edit_data(view) + apply_updates(updated_node_data) + @updated_node + end + + def view + result = {} + result["name"] = node.name + result["chef_environment"] = node.chef_environment + result["normal"] = node.normal_attrs + result["run_list"] = node.run_list + + if config[:all_attributes] + result["default"] = node.default_attrs + result["override"] = node.override_attrs + result["automatic"] = node.automatic_attrs + end + Chef::JSONCompat.to_json_pretty(result) + end + + def edit_data(text) + edited_data = tempfile_for(text) {|filename| system("#{config[:editor]} #{filename}")} + Chef::JSONCompat.from_json(edited_data) + end + + def apply_updates(updated_data) + if node.name and node.name != updated_data["name"] + ui.warn "Changing the name of a node results in a new node being created, #{node.name} will not be modified or removed." + confirm = ui.confirm "Proceed with creation of new node" + end + + @updated_node = Node.new.tap do |n| + n.name( updated_data["name"] ) + n.chef_environment( updated_data["chef_environment"] ) + n.run_list( updated_data["run_list"]) + n.normal_attrs = updated_data["normal"] + + if config[:all_attributes] + n.default_attrs = updated_data["default"] + n.override_attrs = updated_data["override"] + n.automatic_attrs = updated_data["automatic"] + else + n.default_attrs = node.default_attrs + n.override_attrs = node.override_attrs + n.automatic_attrs = node.automatic_attrs + end + end + end + + def updated? + pristine_copy = Chef::JSONCompat.from_json(Chef::JSONCompat.to_json(node), :create_additions => false) + updated_copy = Chef::JSONCompat.from_json(Chef::JSONCompat.to_json(@updated_node), :create_additions => false) + unless pristine_copy == updated_copy + updated_properties = %w{name normal chef_environment run_list default override automatic}.reject do |key| + pristine_copy[key] == updated_copy[key] + end + end + ( pristine_copy != updated_copy ) && updated_properties + end + + private + + def abort(message) + ui.error(message) + exit 1 + end + + def assert_editor_set! + unless config[:editor] + abort "You must set your EDITOR environment variable or configure your editor via knife.rb" + end + end + + def tempfile_for(data) + # TODO: include useful info like the node name in the temp file + # name + basename = "knife-edit-" << rand(1_000_000_000_000_000).to_s.rjust(15, '0') << '.js' + filename = File.join(Dir.tmpdir, basename) + File.open(filename, "w+") do |f| + f.sync = true + f.puts data + end + + yield filename + + IO.read(filename) + ensure + File.unlink(filename) + end + end + end +end + diff --git a/lib/chef/knife/core/node_presenter.rb b/lib/chef/knife/core/node_presenter.rb new file mode 100644 index 0000000000..a35baf2264 --- /dev/null +++ b/lib/chef/knife/core/node_presenter.rb @@ -0,0 +1,137 @@ +# +# Author:: Daniel DeLeo (<dan@opscode.com>) +# Copyright:: Copyright (c) 2011 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 'chef/knife/core/text_formatter' +require 'chef/knife/core/generic_presenter' + +class Chef + class Knife + module Core + + # This module may be included into a knife subcommand class to automatically + # add configuration options used by the NodePresenter + module NodeFormattingOptions + # :nodoc: + # Would prefer to do this in a rational way, but can't be done b/c of + # Mixlib::CLI's design :( + def self.included(includer) + includer.class_eval do + option :medium_output, + :short => '-m', + :long => '--medium', + :boolean => true, + :default => false, + :description => 'Include normal attributes in the output' + + option :long_output, + :short => '-l', + :long => '--long', + :boolean => true, + :default => false, + :description => 'Include all attributes in the output' + end + end + end + + #==Chef::Knife::Core::NodePresenter + # A customized presenter for Chef::Node objects. Supports variable-length + # output formats for displaying node data + class NodePresenter < GenericPresenter + + def format(data) + if parse_format_option == :json + summarize_json(data) + else + super + end + end + + def summarize_json(data) + if data.kind_of?(Chef::Node) + node = data + result = {} + + result["name"] = node.name + result["chef_environment"] = node.chef_environment + result["run_list"] = node.run_list + result["normal"] = node.normal_attrs + + if config[:long_output] + result["default"] = node.default_attrs + result["override"] = node.override_attrs + result["automatic"] = node.automatic_attrs + end + + Chef::JSONCompat.to_json_pretty(result) + else + Chef::JSONCompat.to_json_pretty(data) + end + end + + # Converts a Chef::Node object to a string suitable for output to a + # terminal. If config[:medium_output] or config[:long_output] are set + # the volume of output is adjusted accordingly. Uses colors if enabled + # in the the ui object. + def summarize(data) + if data.kind_of?(Chef::Node) + node = data + # special case ec2 with their split horizon whatsis. + ip = (node[:ec2] && node[:ec2][:public_ipv4]) || node[:ipaddress] + + summarized=<<-SUMMARY +#{ui.color('Node Name:', :bold)} #{ui.color(node.name, :bold)} +#{key('Environment:')} #{node.chef_environment} +#{key('FQDN:')} #{node[:fqdn]} +#{key('IP:')} #{ip} +#{key('Run List:')} #{node.run_list} +#{key('Roles:')} #{Array(node[:roles]).join(', ')} +#{key('Recipes:')} #{Array(node[:recipes]).join(', ')} +#{key('Platform:')} #{node[:platform]} #{node[:platform_version]} +#{key('Tags:')} #{Array(node[:tags]).join(', ')} +SUMMARY + if config[:medium_output] || config[:long_output] + summarized +=<<-MORE +#{key('Attributes:')} +#{text_format(node.normal_attrs)} +MORE + end + if config[:long_output] + summarized +=<<-MOST +#{key('Default Attributes:')} +#{text_format(node.default_attrs)} +#{key('Override Attributes:')} +#{text_format(node.override_attrs)} +#{key('Automatic Attributes (Ohai Data):')} +#{text_format(node.automatic_attrs)} +MOST + end + summarized + else + super + end + end + + def key(key_text) + ui.color(key_text, :cyan) + end + + end + end + end +end + diff --git a/lib/chef/knife/core/object_loader.rb b/lib/chef/knife/core/object_loader.rb new file mode 100644 index 0000000000..1d207c10d1 --- /dev/null +++ b/lib/chef/knife/core/object_loader.rb @@ -0,0 +1,112 @@ +# +# Author:: Daniel DeLeo (<dan@opscode.com>) +# Copyright:: Copyright (c) 2011 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. +# + +class Chef + class Knife + module Core + class ObjectLoader + + attr_reader :ui + attr_reader :klass + + class ObjectType + FILE = 1 + FOLDER = 2 + end + + def initialize(klass, ui) + @klass = klass + @ui = ui + end + + def load_from(repo_location, *components) + unless object_file = find_file(repo_location, *components) + ui.error "Could not find or open file '#{components.last}' in current directory or in '#{repo_location}/#{components.join('/')}'" + exit 1 + end + object_from_file(object_file) + end + + # When someone makes this awesome, please update the above error message. + def find_file(repo_location, *components) + if file_exists_and_is_readable?(File.expand_path( components.last )) + File.expand_path( components.last ) + else + relative_path = File.join(Dir.pwd, repo_location, *components) + if file_exists_and_is_readable?(relative_path) + relative_path + else + nil + end + end + end + + # Find all objects in the given location + # If the object type is File it will look for all *.{json,rb} + # files, otherwise it will lookup for folders only (useful for + # data_bags) + # + # @param [String] path - base look up location + # + # @return [Array<String>] basenames of the found objects + # + # @api public + def find_all_objects(path) + path = File.join(path, '*') + path << '.{json,rb}' + objects = Dir.glob(File.expand_path(path)) + objects.map { |o| File.basename(o) } + end + + def find_all_object_dirs(path) + path = File.join(path, '*') + objects = Dir.glob(File.expand_path(path)) + objects.delete_if { |o| !File.directory?(o) } + objects.map { |o| File.basename(o) } + end + + def object_from_file(filename) + case filename + when /\.(js|json)$/ + r = Yajl::Parser.parse(IO.read(filename)) + + # Chef::DataBagItem doesn't work well with the json_create method + if @klass == Chef::DataBagItem + r + else + @klass.json_create(r) + end + when /\.rb$/ + r = klass.new + r.from_file(filename) + r + else + ui.fatal("File must end in .js, .json, or .rb") + exit 30 + end + end + + def file_exists_and_is_readable?(file) + File.exists?(file) && File.readable?(file) + end + + end + end + end +end + diff --git a/lib/chef/knife/core/subcommand_loader.rb b/lib/chef/knife/core/subcommand_loader.rb new file mode 100644 index 0000000000..314f54bc0b --- /dev/null +++ b/lib/chef/knife/core/subcommand_loader.rb @@ -0,0 +1,112 @@ +# Author:: Christopher Brown (<cb@opscode.com>) +# Author:: Daniel DeLeo (<dan@opscode.com>) +# Copyright:: Copyright (c) 2009, 2011 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 'chef/version' +class Chef + class Knife + class SubcommandLoader + + CHEF_FILE_IN_GEM = /chef-[\d]+\.[\d]+\.[\d]+/ + CURRENT_CHEF_GEM = /chef-#{Regexp.escape(Chef::VERSION)}/ + + attr_reader :chef_config_dir + attr_reader :env + + def initialize(chef_config_dir, env=ENV) + @chef_config_dir, @env = chef_config_dir, env + @forced_activate = {} + end + + # Load all the sub-commands + def load_commands + subcommand_files.each { |subcommand| Kernel.load subcommand } + true + end + + # Returns an Array of paths to knife commands located in chef_config_dir/plugins/knife/ + # and ~/.chef/plugins/knife/ + def site_subcommands + user_specific_files = [] + + if chef_config_dir + user_specific_files.concat Dir.glob(File.expand_path("plugins/knife/*.rb", chef_config_dir)) + end + + # finally search ~/.chef/plugins/knife/*.rb + user_specific_files.concat Dir.glob(File.join(env['HOME'], '.chef', 'plugins', 'knife', '*.rb')) if env['HOME'] + + user_specific_files + end + + # Returns a Hash of paths to knife commands built-in to chef, or installed via gem. + # If rubygems is not installed, falls back to globbing the knife directory. + # The Hash is of the form {"relative/path" => "/absolute/path"} + #-- + # Note: the "right" way to load the plugins is to require the relative path, i.e., + # require 'chef/knife/command' + # but we're getting frustrated by bugs at every turn, and it's slow besides. So + # subcommand loader has been modified to load the plugins by using Kernel.load + # with the absolute path. + def gem_and_builtin_subcommands + # search all gems for chef/knife/*.rb + require 'rubygems' + find_subcommands_via_rubygems + rescue LoadError + find_subcommands_via_dirglob + end + + def subcommand_files + @subcommand_files ||= (gem_and_builtin_subcommands.values + site_subcommands).flatten.uniq + end + + def find_subcommands_via_dirglob + # The "require paths" of the core knife subcommands bundled with chef + files = Dir[File.expand_path('../../../knife/*.rb', __FILE__)] + subcommand_files = {} + files.each do |knife_file| + rel_path = knife_file[/#{CHEF_ROOT}#{Regexp.escape(File::SEPARATOR)}(.*)\.rb/,1] + subcommand_files[rel_path] = knife_file + end + subcommand_files + end + + def find_subcommands_via_rubygems + files = Gem.find_files 'chef/knife/*.rb' + files.reject! {|f| from_old_gem?(f) } + subcommand_files = {} + files.each do |file| + rel_path = file[/(#{Regexp.escape File.join('chef', 'knife', '')}.*)\.rb/, 1] + subcommand_files[rel_path] = file + end + + subcommand_files.merge(find_subcommands_via_dirglob) + end + + private + + # wow, this is a sad hack :( + # Gem.find_files finds files in all versions of a gem, which + # means that if chef 0.10 and 0.9.x are installed, we'll try to + # require, e.g., chef/knife/ec2_server_create, which will cause + # a gem activation error. So remove files from older chef gems. + def from_old_gem?(path) + path =~ CHEF_FILE_IN_GEM && path !~ CURRENT_CHEF_GEM + end + end + end +end diff --git a/lib/chef/knife/core/text_formatter.rb b/lib/chef/knife/core/text_formatter.rb new file mode 100644 index 0000000000..60328488b2 --- /dev/null +++ b/lib/chef/knife/core/text_formatter.rb @@ -0,0 +1,86 @@ +# +# Author:: Daniel DeLeo (<dan@opscode.com>) +# Copyright:: Copyright (c) 2011 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. +# + +class Chef + class Knife + module Core + class TextFormatter + + attr_reader :data + attr_reader :ui + + def initialize(data, ui) + @ui = ui + @data = if data.respond_to?(:display_hash) + data.display_hash + elsif data.kind_of?(Array) + data + elsif data.respond_to?(:to_hash) + data.to_hash + else + data + end + end + + def formatted_data + @formatted_data ||= text_format(data) + end + + def text_format(data) + buffer = '' + + if data.respond_to?(:keys) + justify_width = data.keys.map {|k| k.to_s.size }.max.to_i + 1 + data.sort.each do |key, value| + # key: ['value'] should be printed as key: value + if value.kind_of?(Array) && value.size == 1 && is_singleton(value[0]) + value = value[0] + end + if is_singleton(value) + # Strings are printed as key: value. + justified_key = ui.color("#{key}:".ljust(justify_width), :cyan) + buffer << "#{justified_key} #{value}\n" + else + # Arrays and hashes get indented on their own lines. + buffer << ui.color("#{key}:\n", :cyan) + lines = text_format(value).split("\n") + lines.each { |line| buffer << " #{line}\n" } + end + end + elsif data.kind_of?(Array) + data.each_index do |index| + item = data[index] + buffer << text_format(data[index]) + # Separate items with newlines if it's an array of hashes or an + # array of arrays + buffer << "\n" if !is_singleton(data[index]) && index != data.size-1 + end + else + buffer << "#{data}\n" + end + buffer + end + + def is_singleton(value) + !(value.kind_of?(Array) || value.respond_to?(:keys)) + end + end + end + end +end + diff --git a/lib/chef/knife/core/ui.rb b/lib/chef/knife/core/ui.rb new file mode 100644 index 0000000000..85e9612315 --- /dev/null +++ b/lib/chef/knife/core/ui.rb @@ -0,0 +1,219 @@ +# +# Author:: Adam Jacob (<adam@opscode.com>) +# Author:: Christopher Brown (<cb@opscode.com>) +# Author:: Daniel DeLeo (<dan@opscode.com>) +# Copyright:: Copyright (c) 2009, 2011 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/platform' +require 'chef/knife/core/generic_presenter' + +class Chef + class Knife + + #==Chef::Knife::UI + # The User Interaction class used by knife. + class UI + + extend Forwardable + + attr_reader :stdout + attr_reader :stderr + attr_reader :stdin + attr_reader :config + + attr_reader :presenter + + def_delegator :@presenter, :format_list_for_display + def_delegator :@presenter, :format_for_display + def_delegator :@presenter, :format_cookbook_list_for_display + + def initialize(stdout, stderr, stdin, config) + @stdout, @stderr, @stdin, @config = stdout, stderr, stdin, config + @presenter = Chef::Knife::Core::GenericPresenter.new(self, config) + end + + # Creates a new +presenter_class+ object and uses it to format structured + # data for display. By default, a Chef::Knife::Core::GenericPresenter + # object is used. + def use_presenter(presenter_class) + @presenter = presenter_class.new(self, config) + end + + def highline + @highline ||= begin + require 'highline' + HighLine.new + end + end + + # Prints a message to stdout. Aliased as +info+ for compatibility with + # the logger API. + def msg(message) + stdout.puts message + end + + alias :info :msg + + # Prints a msg to stderr. Used for warn, error, and fatal. + def err(message) + stderr.puts message + end + + # Print a warning message + def warn(message) + err("#{color('WARNING:', :yellow, :bold)} #{message}") + end + + # Print an error message + def error(message) + err("#{color('ERROR:', :red, :bold)} #{message}") + end + + # Print a message describing a fatal error. + def fatal(message) + err("#{color('FATAL:', :red, :bold)} #{message}") + end + + def color(string, *colors) + if color? + highline.color(string, *colors) + else + string + end + end + + # Should colored output be used? For output to a terminal, this is + # determined by the value of `config[:color]`. When output is not to a + # terminal, colored output is never used + def color? + Chef::Config[:color] && stdout.tty? && !Chef::Platform.windows? + end + + def ask(*args, &block) + highline.ask(*args, &block) + end + + def list(*args) + highline.list(*args) + end + + # Formats +data+ using the configured presenter and outputs the result + # via +msg+. Formatting can be customized by configuring a different + # presenter. See +use_presenter+ + def output(data) + msg @presenter.format(data) + end + + # Determines if the output format is a data interchange format, i.e., + # JSON or YAML + def interchange? + @presenter.interchange? + end + + def ask_question(question, opts={}) + question = question + "[#{opts[:default]}] " if opts[:default] + + if opts[:default] and config[:defaults] + opts[:default] + else + stdout.print question + a = stdin.readline.strip + + if opts[:default] + a.empty? ? opts[:default] : a + else + a + end + end + end + + def pretty_print(data) + stdout.puts data + end + + def edit_data(data, parse_output=true) + output = Chef::JSONCompat.to_json_pretty(data) + + if (!config[:disable_editing]) + filename = "knife-edit-" + 0.upto(20) { filename += rand(9).to_s } + filename << ".js" + filename = File.join(Dir.tmpdir, filename) + tf = File.open(filename, "w") + tf.sync = true + tf.puts output + tf.close + raise "Please set EDITOR environment variable" unless system("#{config[:editor]} #{tf.path}") + tf = File.open(filename, "r") + output = tf.gets(nil) + tf.close + File.unlink(filename) + end + + parse_output ? Chef::JSONCompat.from_json(output) : output + end + + def edit_object(klass, name) + object = klass.load(name) + + output = edit_data(object) + + # Only make the save if the user changed the object. + # + # Output JSON for the original (object) and edited (output), then parse + # them without reconstituting the objects into real classes + # (create_additions=false). Then, compare the resulting simple objects, + # which will be Array/Hash/String/etc. + # + # We wouldn't have to do these shenanigans if all the editable objects + # implemented to_hash, or if to_json against a hash returned a string + # with stable key order. + object_parsed_again = Chef::JSONCompat.from_json(Chef::JSONCompat.to_json(object), :create_additions => false) + output_parsed_again = Chef::JSONCompat.from_json(Chef::JSONCompat.to_json(output), :create_additions => false) + if object_parsed_again != output_parsed_again + output.save + self.msg("Saved #{output}") + else + self.msg("Object unchanged, not saving") + end + output(format_for_display(object)) if config[:print_after] + end + + def confirm(question, append_instructions=true) + return true if config[:yes] + + stdout.print question + stdout.print "? (Y/N) " if append_instructions + answer = stdin.readline + answer.chomp! + case answer + when "Y", "y" + true + when "N", "n" + self.msg("You said no, so I'm done here.") + exit 3 + else + self.msg("I have no idea what to do with #{answer}") + self.msg("Just say Y or N, please.") + confirm(question) + end + end + + end + end +end |