diff options
-rw-r--r-- | chef-config/lib/chef-config/config.rb | 33 | ||||
-rw-r--r-- | chef-config/spec/unit/config_spec.rb | 64 | ||||
-rw-r--r-- | chef.gemspec | 1 | ||||
-rw-r--r-- | lib/chef/application/client.rb | 15 | ||||
-rw-r--r-- | lib/chef/client.rb | 39 | ||||
-rw-r--r-- | lib/chef/dsl/universal.rb | 1 | ||||
-rw-r--r-- | lib/chef/exceptions.rb | 2 | ||||
-rw-r--r-- | lib/chef/formatters/doc.rb | 1 | ||||
-rw-r--r-- | lib/chef/formatters/minimal.rb | 1 | ||||
-rw-r--r-- | lib/chef/guard_interpreter/default_guard_interpreter.rb | 4 | ||||
-rw-r--r-- | lib/chef/mixin/train_or_shell.rb | 74 | ||||
-rw-r--r-- | lib/chef/node_map.rb | 17 | ||||
-rw-r--r-- | lib/chef/provider/execute.rb | 7 | ||||
-rw-r--r-- | lib/chef/resource/execute.rb | 3 | ||||
-rw-r--r-- | lib/chef/run_context.rb | 19 | ||||
-rw-r--r-- | lib/chef/train_transport.rb | 129 | ||||
-rw-r--r-- | spec/unit/node_map_spec.rb | 40 | ||||
-rw-r--r-- | spec/unit/train_transport_spec.rb | 79 |
18 files changed, 511 insertions, 18 deletions
diff --git a/chef-config/lib/chef-config/config.rb b/chef-config/lib/chef-config/config.rb index 0c257a49b5..910f0e024d 100644 --- a/chef-config/lib/chef-config/config.rb +++ b/chef-config/lib/chef-config/config.rb @@ -293,10 +293,11 @@ module ChefConfig # the cache path. unless path_accessible?(primary_cache_path) || path_accessible?(primary_cache_root) secondary_cache_path = PathHelper.join(user_home, ".chef") + secondary_cache_path = target_mode? ? "#{secondary_cache_path}/#{target_mode.host}" : secondary_cache_path ChefConfig.logger.trace("Unable to access cache at #{primary_cache_path}. Switching cache to #{secondary_cache_path}") secondary_cache_path else - primary_cache_path + target_mode? ? "#{primary_cache_path}/#{target_mode.host}" : primary_cache_path end end end @@ -435,6 +436,22 @@ module ChefConfig # * Chef 11 mode doesn't expose RBAC objects default :osc_compat, false end + + # RFCxxx Target Mode support, value is the name of a remote device to Chef against + # --target exists as a shortcut to enabling target_mode and setting the host + configurable(:target) + + config_context :target_mode do + config_strict_mode false # we don't want to have to add all train configuration keys here + default :enabled, false + default :protocol, "ssh" + # typical additional keys: host, user, password + end + + def self.target_mode? + target_mode.enabled + end + default :chef_server_url, "https://localhost:443" default(:chef_server_root) do @@ -625,7 +642,15 @@ module ChefConfig # `node_name` of the client. # # If chef-zero is enabled, this defaults to nil (no authentication). - default(:client_key) { chef_zero.enabled ? nil : platform_specific_path("/etc/chef/client.pem") } + default(:client_key) do + if chef_zero.enabled + nil + elsif target_mode? + platform_specific_path("/etc/chef/#{target_mode.host}/client.pem") + else + platform_specific_path("/etc/chef/client.pem") + end + end # A credentials file may contain a complete client key, rather than the path # to one. @@ -645,7 +670,9 @@ module ChefConfig # This secret is used to decrypt encrypted data bag items. default(:encrypted_data_bag_secret) do - if File.exist?(platform_specific_path("/etc/chef/encrypted_data_bag_secret")) + if target_mode? && File.exist?(platform_specific_path("/etc/chef/#{target_mode.host}/encrypted_data_bag_secret")) + platform_specific_path("/etc/chef/#{target_mode.host}/encrypted_data_bag_secret") + elsif File.exist?(platform_specific_path("/etc/chef/encrypted_data_bag_secret")) platform_specific_path("/etc/chef/encrypted_data_bag_secret") else nil diff --git a/chef-config/spec/unit/config_spec.rb b/chef-config/spec/unit/config_spec.rb index 0e17753185..378cc4a4a1 100644 --- a/chef-config/spec/unit/config_spec.rb +++ b/chef-config/spec/unit/config_spec.rb @@ -248,9 +248,10 @@ RSpec.describe ChefConfig::Config do end describe "default values" do + let(:system_drive) { ChefConfig::Config.env["SYSTEMDRIVE"] } if is_windows let :primary_cache_path do if is_windows - "#{ChefConfig::Config.env['SYSTEMDRIVE']}\\chef" + "#{system_drive}\\chef" else "/var/chef" end @@ -275,6 +276,35 @@ RSpec.describe ChefConfig::Config do allow(ChefConfig::Config).to receive(:path_accessible?).and_return(false) end + describe "ChefConfig::Config[:client_key]" do + let(:path_to_client_key) { to_platform("/etc/chef") + ChefConfig::PathHelper.path_separator } + + it "sets the default path to the client key" do + expect(ChefConfig::Config.client_key).to eq(path_to_client_key + "client.pem") + end + + context "when target mode is enabled" do + let(:target_mode_host) { "fluffy.kittens.org" } + + before do + ChefConfig::Config.target_mode.enabled = true + ChefConfig::Config.target_mode.host = target_mode_host + end + + it "sets the default path to the client key with the target host name" do + expect(ChefConfig::Config.client_key).to eq(path_to_client_key + target_mode_host + ChefConfig::PathHelper.path_separator + "client.pem") + end + end + + context "when local mode is enabled" do + before { ChefConfig::Config[:local_mode] = true } + + it "returns nil" do + expect(ChefConfig::Config.client_key).to be_nil + end + end + end + describe "ChefConfig::Config[:fips]" do let(:fips_enabled) { false } @@ -370,16 +400,32 @@ RSpec.describe ChefConfig::Config do end describe "ChefConfig::Config[:cache_path]" do + let(:target_mode_host) { "fluffy.kittens.org" } + let(:target_mode_primary_cache_path) { "#{primary_cache_path}/#{target_mode_host}" } + let(:target_mode_secondary_cache_path) { "#{secondary_cache_path}/#{target_mode_host}" } + before do if is_windows - allow(File).to receive(:expand_path).and_return("#{ChefConfig::Config.env["SYSTEMDRIVE"]}/Path/To/Executable") + allow(File).to receive(:expand_path).and_return("#{system_drive}/Path/To/Executable") end end + context "when /var/chef exists and is accessible" do - it "defaults to /var/chef" do + before do allow(ChefConfig::Config).to receive(:path_accessible?).with(to_platform("/var/chef")).and_return(true) + end + + it "defaults to /var/chef" do expect(ChefConfig::Config[:cache_path]).to eq(primary_cache_path) end + + context "and target mode is enabled" do + it "cache path includes the target host name" do + ChefConfig::Config.target_mode.enabled = true + ChefConfig::Config.target_mode.host = target_mode_host + expect(ChefConfig::Config[:cache_path]).to eq(target_mode_primary_cache_path) + end + end end context "when /var/chef does not exist and /var is accessible" do @@ -399,13 +445,23 @@ RSpec.describe ChefConfig::Config do end context "when /var/chef exists and is not accessible" do - it "defaults to $HOME/.chef" do + before do allow(File).to receive(:exists?).with(to_platform("/var/chef")).and_return(true) allow(File).to receive(:readable?).with(to_platform("/var/chef")).and_return(true) allow(File).to receive(:writable?).with(to_platform("/var/chef")).and_return(false) + end + it "defaults to $HOME/.chef" do expect(ChefConfig::Config[:cache_path]).to eq(secondary_cache_path) end + + context "and target mode is enabled" do + it "cache path defaults to $HOME/.chef with the target host name" do + ChefConfig::Config.target_mode.enabled = true + ChefConfig::Config.target_mode.host = target_mode_host + expect(ChefConfig::Config[:cache_path]).to eq(target_mode_secondary_cache_path) + end + end end context "when chef is running in local mode" do diff --git a/chef.gemspec b/chef.gemspec index 7c365eeeb4..975d51dba3 100644 --- a/chef.gemspec +++ b/chef.gemspec @@ -39,6 +39,7 @@ Gem::Specification.new do |s| s.add_dependency "diff-lcs", "~> 1.2", ">= 1.2.4" s.add_dependency "ffi-libarchive" s.add_dependency "chef-zero", ">= 14.0.11" + s.add_dependency "plist", "~> 3.2" s.add_dependency "iniparse", "~> 1.4" s.add_dependency "addressable" diff --git a/lib/chef/application/client.rb b/lib/chef/application/client.rb index 31932b812c..2c63a54f33 100644 --- a/lib/chef/application/client.rb +++ b/lib/chef/application/client.rb @@ -301,6 +301,15 @@ class Chef::Application::Client < Chef::Application description: "Use cached cookbooks without overwriting local differences from the #{Chef::Dist::SERVER_PRODUCT}.", boolean: false + option :target, + short: "-t TARGET", + long: "--target TARGET", + description: "Target #{Chef::Dist::PRODUCT} against a remote system or device", + proc: lambda { |target| + Chef::Log.warn "-- EXPERIMENTAL -- Target mode activated, resources and dsl may change without warning -- EXPERIMENTAL --" + target + } + IMMEDIATE_RUN_SIGNAL = "1".freeze RECONFIGURE_SIGNAL = "H".freeze @@ -351,6 +360,12 @@ class Chef::Application::Client < Chef::Application Chef::Config.chef_zero.host = config[:chef_zero_host] if config[:chef_zero_host] Chef::Config.chef_zero.port = config[:chef_zero_port] if config[:chef_zero_port] + if config[:target] || Chef::Config.target + Chef::Config.target_mode.enabled = true + Chef::Config.target_mode.host = config[:target] || Chef::Config.target + Chef::Config.node_name = Chef::Config.target_mode.host unless Chef::Config.node_name + end + if Chef::Config[:daemonize] Chef::Config[:interval] ||= 1800 end diff --git a/lib/chef/client.rb b/lib/chef/client.rb index 0c4acc0a8d..a11662b7d8 100644 --- a/lib/chef/client.rb +++ b/lib/chef/client.rb @@ -55,6 +55,7 @@ require "chef/mixin/deprecation" require "ohai" require "rbconfig" require "chef/dist" +require "forwardable" class Chef # == Chef::Client @@ -65,6 +66,7 @@ class Chef extend Chef::Mixin::Deprecation + extend Forwardable # # The status of the Chef run. # @@ -136,6 +138,9 @@ class Chef attr_reader :events attr_reader :logger + + def_delegator :@run_context, :transport_connection + # # Creates a new Chef::Client. # @@ -244,9 +249,15 @@ class Chef logger.info("*** #{Chef::Dist::PRODUCT} #{Chef::VERSION} ***") logger.info("Platform: #{RUBY_PLATFORM}") logger.info "#{Chef::Dist::CLIENT.capitalize} pid: #{Process.pid}" + logger.info "Targeting node: #{Chef::Config.target_mode.host}" if Chef::Config.target_mode? logger.debug("#{Chef::Dist::CLIENT.capitalize} request_id: #{request_id}") enforce_path_sanity - run_ohai + + if Chef::Config.target_mode? + get_ohai_data_remotely + else + run_ohai + end unless Chef::Config[:solo_legacy_mode] register @@ -556,6 +567,32 @@ class Chef end # + # Populate the minimal ohai attributes defined in #run_ohai with data train collects. + # + # Eventually ohai may support colleciton of data. + # + def get_ohai_data_remotely + ohai.data[:fqdn] = if transport_connection.respond_to?(:hostname) + transport_connection.hostname + else + Chef::Config[:target_mode][:host] + end + if transport_connection.respond_to?(:os) + ohai.data[:platform] = transport_connection.os.name + ohai.data[:platform_version] = transport_connection.os.release + ohai.data[:os] = transport_connection.os.family_hierarchy[1] + ohai.data[:platform_family] = transport_connection.os.family + end + # train does not collect these specifically + # ohai.data[:machinename] = nil + # ohai.data[:hostname] = nil + # ohai.data[:os_version] = nil # kernel version + + ohai.data[:ohai_time] = Time.now.to_f + events.ohai_completed(node) + end + + # # Run ohai plugins. Runs all ohai plugins unless minimal_ohai is specified. # # Sends the ohai_completed event when finished. diff --git a/lib/chef/dsl/universal.rb b/lib/chef/dsl/universal.rb index eb90acfa2c..256bc2820d 100644 --- a/lib/chef/dsl/universal.rb +++ b/lib/chef/dsl/universal.rb @@ -25,6 +25,7 @@ require "chef/mixin/powershell_exec" require "chef/mixin/powershell_out" require "chef/mixin/shell_out" require "chef/mixin/lazy_module_include" +require "chef/mixin/train_or_shell" class Chef module DSL diff --git a/lib/chef/exceptions.rb b/lib/chef/exceptions.rb index 4ab9434906..f109b9406e 100644 --- a/lib/chef/exceptions.rb +++ b/lib/chef/exceptions.rb @@ -132,7 +132,7 @@ class Chef # Can't find a Resource of this type that is valid on this platform. class NoSuchResourceType < NameError def initialize(short_name, node) - super "Cannot find a resource for #{short_name} on #{node[:platform]} version #{node[:platform_version]}" + super "Cannot find a resource for #{short_name} on #{node[:platform]} version #{node[:platform_version]} with target_mode? #{Chef::Config.target_mode?}" end end diff --git a/lib/chef/formatters/doc.rb b/lib/chef/formatters/doc.rb index 1fdbb66b1b..d1849a72d9 100644 --- a/lib/chef/formatters/doc.rb +++ b/lib/chef/formatters/doc.rb @@ -42,6 +42,7 @@ class Chef def run_start(version, run_status) puts_line "Starting #{Chef::Dist::PRODUCT}, version #{version}" + puts_line "Targeting node: #{Chef::Config.target_mode.host}" if Chef::Config.target_mode? puts_line "OpenSSL FIPS 140 mode enabled" if Chef::Config[:fips] end diff --git a/lib/chef/formatters/minimal.rb b/lib/chef/formatters/minimal.rb index 6b12be71b7..1a755afd94 100644 --- a/lib/chef/formatters/minimal.rb +++ b/lib/chef/formatters/minimal.rb @@ -29,6 +29,7 @@ class Chef # Called at the very start of a Chef Run def run_start(version, run_status) puts_line "Starting #{Chef::Dist::PRODUCT}, version #{version}" + puts_line "Targeting node: #{Chef::Config.target_mode.host}" if Chef::Config.target_mode? puts_line "OpenSSL FIPS 140 mode enabled" if Chef::Config[:fips] end diff --git a/lib/chef/guard_interpreter/default_guard_interpreter.rb b/lib/chef/guard_interpreter/default_guard_interpreter.rb index c4c09ac47a..402f05f288 100644 --- a/lib/chef/guard_interpreter/default_guard_interpreter.rb +++ b/lib/chef/guard_interpreter/default_guard_interpreter.rb @@ -17,11 +17,13 @@ # require "chef/mixin/shell_out" +require "chef/mixin/train_or_shell" class Chef class GuardInterpreter class DefaultGuardInterpreter include Chef::Mixin::ShellOut + include Chef::Mixin::TrainOrShell protected @@ -33,7 +35,7 @@ class Chef public def evaluate - result = shell_out(@command, default_env: false, **@command_opts) + result = train_or_shell(@command, default_env: false, **@command_opts) Chef::Log.debug "Command failed: #{result.stderr}" unless result.status.success? result.status.success? # Timeout fails command rather than chef-client run, see: diff --git a/lib/chef/mixin/train_or_shell.rb b/lib/chef/mixin/train_or_shell.rb new file mode 100644 index 0000000000..c976094b14 --- /dev/null +++ b/lib/chef/mixin/train_or_shell.rb @@ -0,0 +1,74 @@ +# +# Author:: Bryan McLellan <btm@loftninjas.org> +# Copyright:: Copyright 2018, 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. +# + +require "ostruct" + +class Chef + module Mixin + module TrainOrShell + # + # Train #run_command returns a Train::Extras::CommandResult which + # includes `exit_status` but Mixlib::Shellout returns exitstatus + # This wrapper makes the result look like Mixlib::ShellOut to make it + # easier to swap providers from #shell_out to #train_or_shell + # + def train_to_shellout_result(stdout, stderr, exit_status) + status = OpenStruct.new(success?: ( exit_status == 0 )) + OpenStruct.new(stdout: stdout, stderr: stderr, exitstatus: exit_status, status: status) + end + + def train_or_shell(*args, **opts) + if Chef::Config.target_mode? + result = run_context.transport_connection.run_command(args) + train_to_shellout_result(result.stdout, result.stderr, result.exit_status) + else + shell_out(*args, opts) + end + end + + def train_or_shell!(*args, **opts) + if Chef::Config.target_mode? + result = run_context.transport_connection.run_command(*args) + raise Mixlib::ShellOut::ShellCommandFailed, "Unexpected exit status of #{result.exit_status} running #{args}" if result.exit_status != 0 + train_to_shellout_result(result.stdout, result.stderr, result.exit_status) + else + shell_out!(*args, opts) + end + end + + def train_or_powershell(*args, **opts) + if Chef::Config.target_mode? + run_context.transport_connection.run_command(args) + train_to_shellout_result(result.stdout, result.stderr, result.exit_status) + else + powershell_out(*args) + end + end + + def train_or_powershell!(*args, **opts) + if Chef::Config.target_mode? + result = run_context.transport_connection.run_command(args) + raise Mixlib::ShellOut::ShellCommandFailed, "Unexpected exit status of #{result.exit_status} running #{args}" if result.exit_status != 0 + train_to_shellout_result(result.stdout, result.stderr, result.exit_status) + else + powershell_out!(*args) + end + end + end + end +end diff --git a/lib/chef/node_map.rb b/lib/chef/node_map.rb index 50a763f686..e66e249a97 100644 --- a/lib/chef/node_map.rb +++ b/lib/chef/node_map.rb @@ -66,7 +66,7 @@ class Chef # # @return [NodeMap] Returns self for possible chaining # - def set(key, klass, platform: nil, platform_version: nil, platform_family: nil, os: nil, canonical: nil, override: nil, allow_cookbook_override: false, __core_override__: false, chef_version: nil, &block) # rubocop:disable Lint/UnderscorePrefixedVariableName + def set(key, klass, platform: nil, platform_version: nil, platform_family: nil, os: nil, canonical: nil, override: nil, allow_cookbook_override: false, __core_override__: false, chef_version: nil, target_mode: nil, &block) # rubocop:disable Lint/UnderscorePrefixedVariableName new_matcher = { klass: klass } new_matcher[:platform] = platform if platform new_matcher[:platform_version] = platform_version if platform_version @@ -77,6 +77,7 @@ class Chef new_matcher[:override] = override if override new_matcher[:cookbook_override] = allow_cookbook_override new_matcher[:core_override] = __core_override__ + new_matcher[:target_mode] = target_mode if chef_version && Chef::VERSION !~ chef_version return map @@ -269,11 +270,21 @@ class Chef end end + # Succeeds if: + # - we are in target mode, and the target_mode filter is true + # - we are not in target mode + # + def matches_target_mode?(filters) + return true unless Chef::Config.target_mode? + !!filters[:target_mode] + end + def filters_match?(node, filters) matches_black_white_list?(node, filters, :os) && matches_black_white_list?(node, filters, :platform_family) && matches_black_white_list?(node, filters, :platform) && - matches_version_list?(node, filters, :platform_version) + matches_version_list?(node, filters, :platform_version) && + matches_target_mode?(filters) end def block_matches?(node, block) @@ -295,6 +306,8 @@ class Chef # "provides" lines with identical filters sort by class name (ascending). # def compare_matchers(key, new_matcher, matcher) + cmp = compare_matcher_properties(new_matcher[:target_mode], matcher[:target_mode]) + return cmp if cmp != 0 cmp = compare_matcher_properties(new_matcher[:block], matcher[:block]) return cmp if cmp != 0 cmp = compare_matcher_properties(new_matcher[:platform_version], matcher[:platform_version]) diff --git a/lib/chef/provider/execute.rb b/lib/chef/provider/execute.rb index 02db7cb593..2d84ac317b 100644 --- a/lib/chef/provider/execute.rb +++ b/lib/chef/provider/execute.rb @@ -19,16 +19,19 @@ require "chef/log" require "chef/provider" require "forwardable" +require "chef/mixin/train_or_shell" class Chef class Provider class Execute < Chef::Provider extend Forwardable - provides :execute + provides :execute, target_mode: true def_delegators :new_resource, :command, :returns, :environment, :user, :domain, :password, :group, :cwd, :umask, :creates, :elevated, :default_env + include Chef::Mixin::TrainOrShell + def load_current_resource current_resource = Chef::Resource::Execute.new(new_resource.name) current_resource @@ -55,7 +58,7 @@ class Chef converge_by("execute #{description}") do begin - shell_out!(command, opts) + train_or_shell!(command, opts) rescue Mixlib::ShellOut::ShellCommandFailed if sensitive? ex = Mixlib::ShellOut::ShellCommandFailed.new("Command execution failed. STDOUT/STDERR suppressed for sensitive resource") diff --git a/lib/chef/resource/execute.rb b/lib/chef/resource/execute.rb index de3b7e044a..295cf357cb 100644 --- a/lib/chef/resource/execute.rb +++ b/lib/chef/resource/execute.rb @@ -24,7 +24,8 @@ class Chef class Resource class Execute < Chef::Resource resource_name :execute - provides :execute + provides :execute, target_mode: true + description "Use the execute resource to execute a single command. Commands that"\ " are executed with this resource are (by their nature) not idempotent,"\ " as they are typically unique to the environment in which they are run."\ diff --git a/lib/chef/run_context.rb b/lib/chef/run_context.rb index dc322b254f..403a30f6fb 100644 --- a/lib/chef/run_context.rb +++ b/lib/chef/run_context.rb @@ -25,6 +25,7 @@ require "chef/log" require "chef/recipe" require "chef/run_context/cookbook_compiler" require "chef/event_dispatch/events_output_stream" +require "chef/train_transport" require "forwardable" class Chef @@ -590,6 +591,22 @@ class Chef reboot_info.size > 0 end + # Remote transport from Train + # + # @return [Train::Plugins::Transport] The child class for our train transport. + # + def transport + @transport ||= Chef::TrainTransport.build_transport(logger) + end + + # Remote connection object from Train + # + # @return [Train::Plugins::Transport::BaseConnection] + # + def transport_connection + transport.connection + end + # # Create a child RunContext. # @@ -650,6 +667,8 @@ class Chef rest_clean rest_clean= unreachable_cookbook? + transport + transport_connection } def initialize(parent_run_context) diff --git a/lib/chef/train_transport.rb b/lib/chef/train_transport.rb new file mode 100644 index 0000000000..9db5f8fbf3 --- /dev/null +++ b/lib/chef/train_transport.rb @@ -0,0 +1,129 @@ +# Author:: Bryan McLellan <btm@loftninjas.org> +# Copyright:: Copyright 2018, 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. +# + +require "chef-config/mixin/credentials" +require "train" + +class Chef + class TrainTransport + # + # Returns a RFC099 credentials profile as a hash + # + def self.load_credentials(profile) + extend ChefConfig::Mixin::Credentials + + # Tomlrb.load_file returns a hash with keys as strings + credentials = parse_credentials_file + if contains_split_fqdn?(credentials, profile) + Chef::Log.warn("Credentials file #{credentials_file_path} contains target '#{profile}' as a Hash, expected a string.") + Chef::Log.warn("Hostnames must be surrounded by single quotes, e.g. ['host.example.org']") + end + + # host names must be specified in credentials file as ['foo.example.org'] with quotes + if !credentials.nil? && !credentials[profile].nil? + credentials[profile].map { |k, v| [k.to_sym, v] }.to_h # return symbolized keys to match Train.options() + else + nil + end + end + + # Toml creates hashes when a key is separated by periods, e.g. + # [host.example.org] => { host: { example: { org: {} } } } + # + # Returns true if the above example is true + # + # A hostname has to be specified as ['host.example.org'] + # This will be a common mistake so we should catch it + # + def self.contains_split_fqdn?(hash, fqdn) + fqdn.split(".").reduce(hash) do |h, k| + v = h[k] + if Hash === v + v + else + break false + end + end + end + + # ChefConfig::Mixin::Credentials.credentials_file_path is designed around knife, + # overriding it here. + # + # Credentials file preference: + # + # 1) target_mode.credentials_file + # 2) /etc/chef/TARGET_MODE_HOST/credentials + # 3) #credentials_file_path from parent ($HOME/.chef/credentials) + # + def self.credentials_file_path + tm_config = Chef::Config.target_mode + profile = tm_config.host + + credentials_file = + if tm_config.credentials_file + if File.exists?(tm_config.credentials_file) + tm_config.credentials_file + else + raise ArgumentError, "Credentials file specified for target mode does not exist: '#{tm_config.credentials_file}'" + end + elsif File.exists?(Chef::Config.platform_specific_path("/etc/chef/#{profile}/credentials")) + Chef::Config.platform_specific_path("/etc/chef/#{profile}/credentials") + else + super + end + if credentials_file + Chef::Log.debug("Loading credentials file '#{credentials_file}' for target '#{profile}'") + else + Chef::Log.debug("No credentials file found for target '#{profile}'") + end + + credentials_file + end + + def self.build_transport(logger = Chef::Log.with_child(subsystem: "transport")) + # TODO: Consider supporting parsing the protocol from a URI passed to `--target` + # + train_config = Hash.new + + # Load the target_mode config context from Chef::Config, and place any valid settings into the train configuration + tm_config = Chef::Config.target_mode + protocol = tm_config.protocol + train_config = tm_config.to_hash.select { |k| Train.options(protocol).key?(k) } + Chef::Log.trace("Using target mode options from Chef config file: #{train_config.keys.join(', ')}") if train_config + + # Load the credentials file, and place any valid settings into the train configuration + credentials = load_credentials(tm_config.host) + if credentials + valid_settings = credentials.select { |k| Train.options(protocol).key?(k) } + valid_settings[:enable_password] = credentials[:enable_password] if credentials.key?(:enable_password) + train_config.merge!(valid_settings) + Chef::Log.trace("Using target mode options from credentials file: #{valid_settings.keys.join(', ')}") if valid_settings + end + + train_config[:logger] = logger + + # Train handles connection retries for us + Train.create(protocol, train_config) + rescue SocketError => e # likely a dns failure, not caught by train + e.message.replace "Error connecting to #{train_config[:target]} - #{e.message}" + raise e + rescue Train::PluginLoadError + logger.error("Invalid target mode protocol: #{protocol}") + exit(false) + end + end +end diff --git a/spec/unit/node_map_spec.rb b/spec/unit/node_map_spec.rb index 9c161f3893..7c867857dc 100644 --- a/spec/unit/node_map_spec.rb +++ b/spec/unit/node_map_spec.rb @@ -145,14 +145,14 @@ describe Chef::NodeMap do describe "deleting classes" do it "deletes a class and removes the mapping completely" do node_map.set(:thing, Bar) - expect( node_map.delete_class(Bar) ).to include({ thing: [{ klass: Bar, cookbook_override: false, core_override: false }] }) + expect( node_map.delete_class(Bar) ).to include({ thing: [{ klass: Bar, cookbook_override: false, core_override: false, target_mode: nil }] }) expect( node_map.get(node, :thing) ).to eql(nil) end it "deletes a class and leaves the mapping that still has an entry" do node_map.set(:thing, Bar) node_map.set(:thing, Foo) - expect( node_map.delete_class(Bar) ).to eql({ thing: [{ klass: Bar, cookbook_override: false, core_override: false }] }) + expect( node_map.delete_class(Bar) ).to eql({ thing: [{ klass: Bar, cookbook_override: false, core_override: false, target_mode: nil }] }) expect( node_map.get(node, :thing) ).to eql(Foo) end @@ -160,7 +160,7 @@ describe Chef::NodeMap do node_map.set(:thing1, Bar) node_map.set(:thing2, Bar) node_map.set(:thing2, Foo) - expect( node_map.delete_class(Bar) ).to eql({ thing1: [{ klass: Bar, cookbook_override: false, core_override: false }], thing2: [{ klass: Bar, cookbook_override: false, core_override: false }] }) + expect( node_map.delete_class(Bar) ).to eql({ thing1: [{ klass: Bar, cookbook_override: false, core_override: false, target_mode: nil }], thing2: [{ klass: Bar, cookbook_override: false, core_override: false, target_mode: nil }] }) expect( node_map.get(node, :thing1) ).to eql(nil) expect( node_map.get(node, :thing2) ).to eql(Foo) end @@ -210,6 +210,40 @@ describe Chef::NodeMap do end end + # When in target mode, only match when target_mode is explicitly supported + context "when target mode is enabled" do + before do + allow(Chef::Config).to receive(:target_mode?).and_return(true) + end + + it "returns the value when target_mode matches" do + node_map.set(:something, :network, target_mode: true) + expect(node_map.get(node, :something)).to eql(:network) + end + + it "returns nil when target_mode does not match" do + node_map.set(:something, :local, target_mode: false) + expect(node_map.get(node, :something)).to eql(nil) + end + end + + # When not in target mode, match regardless of target_mode filter + context "when target mode is not enabled" do + before do + allow(Chef::Config).to receive(:target_mode?).and_return(false) + end + + it "returns the value if target_mode matches" do + node_map.set(:something, :local, target_mode: true) + expect(node_map.get(node, :something)).to eql(:local) + end + + it "returns the value if target_mode does not match" do + node_map.set(:something, :local, target_mode: false) + expect(node_map.get(node, :something)).to eql(:local) + end + end + describe "locked mode" do context "while unlocked" do it "allows setting the same key twice" do diff --git a/spec/unit/train_transport_spec.rb b/spec/unit/train_transport_spec.rb new file mode 100644 index 0000000000..b56c7e1104 --- /dev/null +++ b/spec/unit/train_transport_spec.rb @@ -0,0 +1,79 @@ +# +# Author:: Bryan McLellan (<btm@loftninjas.org>) +# Copyright:: Copyright 2019, 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. +# + +require "spec_helper" + +describe Chef::TrainTransport do + describe "load_credentials" do + let(:transport) { Chef::TrainTransport.new } + let(:good_credentials) { { "switch.cisco.com" => { "user" => "cisco", "password" => "cisco", "enable_password" => "secret" } } } + + before do + allow(Chef::TrainTransport).to receive(:parse_credentials_file).and_return(good_credentials) + end + + it "matches credentials when they exist" do + expect(Chef::TrainTransport.load_credentials("switch.cisco.com")[:user]).to eq("cisco") + expect(Chef::TrainTransport.load_credentials("switch.cisco.com")[:password]).to eq("cisco") + expect(Chef::TrainTransport.load_credentials("switch.cisco.com")[:enable_password]).to eq("secret") + end + + it "returns nil if there is no match" do + expect(Chef::TrainTransport.load_credentials("router.unicorns.com")).to be_nil + end + + # [foo.example.org] => {"foo"=>{"example"=>{"org"=>{}}}} + # ['foo.example.org'] => {"foo.example.org"=>{}} + it "warns if the host has been split by toml" do + allow(Chef::TrainTransport).to receive(:parse_credentials_file).and_return({ "foo" => { "example" => { "org" => {} } } }) + expect(Chef::Log).to receive(:warn).with(/as a Hash/) + expect(Chef::Log).to receive(:warn).with(/Hostnames must be surrounded by single quotes/) + expect(Chef::TrainTransport.load_credentials("foo.example.org")).to be_nil + end + end + + describe "credentials_file_path" do + + context "when a file path is specified by a config" do + let(:cred_file_path) { "/somewhere/credentials" } + + before do + tm_config = double("Config Context", host: "foo.example.org", credentials_file: cred_file_path) + allow(Chef::Config).to receive(:target_mode).and_return(tm_config) + end + + it "returns the path if it exists" do + allow(File).to receive(:exists?).with(cred_file_path).and_return(true) + expect(Chef::TrainTransport.credentials_file_path).to eq(cred_file_path) + end + + it "raises an error if it does not exist" do + allow(File).to receive(:exists?).with(cred_file_path).and_return(false) + expect { Chef::TrainTransport.credentials_file_path }.to raise_error(ArgumentError, /does not exist/) + end + end + + it "returns the path to the default config file if it exists" do + cred_file_path = Chef::Config.platform_specific_path("/etc/chef/foo.example.org/credentials") + tm_config = double("Config Context", host: "foo.example.org", credentials_file: nil) + allow(Chef::Config).to receive(:target_mode).and_return(tm_config) + allow(File).to receive(:exists?).with(cred_file_path).and_return(true) + expect(Chef::TrainTransport.credentials_file_path).to eq(cred_file_path) + end + end +end |