diff options
author | Tim Smith <tsmith@chef.io> | 2019-04-29 16:03:24 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-04-29 16:03:24 -0700 |
commit | 0acc51c23e8149e6d6fe675e801c95b1a4e77e84 (patch) | |
tree | 184fd2199c795a223f42d2bf0c87adfc56985ca0 | |
parent | 945b9f6636bb3236999ca43f313113bf74045d82 (diff) | |
parent | e583616cd435be79142b5dfb676cfc9003198474 (diff) | |
download | chef-0acc51c23e8149e6d6fe675e801c95b1a4e77e84.tar.gz |
Merge pull request #8419 from chef/mp/train-alone
Implement bootstrap directly with train
-rw-r--r-- | Gemfile.lock | 48 | ||||
-rw-r--r-- | chef.gemspec | 2 | ||||
-rw-r--r-- | lib/chef.rb | 12 | ||||
-rw-r--r-- | lib/chef/knife/bootstrap.rb | 104 | ||||
-rw-r--r-- | lib/chef/knife/bootstrap/train_connector.rb | 286 | ||||
-rw-r--r-- | spec/support/shared/integration/integration_helper.rb | 1 | ||||
-rw-r--r-- | spec/unit/knife/bootstrap/train_connector_spec.rb | 155 | ||||
-rw-r--r-- | spec/unit/knife/bootstrap_spec.rb | 83 |
8 files changed, 534 insertions, 157 deletions
diff --git a/Gemfile.lock b/Gemfile.lock index 82c2fed634..43b7e06845 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,7 +8,7 @@ GIT GIT remote: https://github.com/chef/ohai.git - revision: 5c70e89388ebc8ddf7d2d7bfd489024b11c0aece + revision: a1ec73298d623d18cbe05c2b53ac57c8cc9beae0 branch: master specs: ohai (15.0.34) @@ -32,7 +32,6 @@ PATH bcrypt_pbkdf (~> 1.0) bundler (>= 1.10) chef-config (= 15.0.237) - chef-core (~> 0.0.3) chef-zero (>= 14.0.11) diff-lcs (~> 1.2, >= 1.2.4) ed25519 (~> 1.2) @@ -54,6 +53,7 @@ PATH plist (~> 3.2) proxifier (~> 1.0) syslog-logger (~> 1.6) + train-core (~> 2.0, >= 2.0.12) tty-screen (~> 0.6) uuidtools (~> 2.1.5) chef (15.0.237-universal-mingw32) @@ -61,7 +61,6 @@ PATH bcrypt_pbkdf (~> 1.0) bundler (>= 1.10) chef-config (= 15.0.237) - chef-core (~> 0.0.3) chef-zero (>= 14.0.11) diff-lcs (~> 1.2, >= 1.2.4) ed25519 (~> 1.2) @@ -84,6 +83,7 @@ PATH plist (~> 3.2) proxifier (~> 1.0) syslog-logger (~> 1.6) + train-core (~> 2.0, >= 2.0.12) tty-screen (~> 0.6) uuidtools (~> 2.1.5) win32-api (~> 1.5.3) @@ -113,7 +113,7 @@ GEM specs: addressable (2.5.2) public_suffix (>= 2.0.2, < 4.0) - appbundler (0.12.4) + appbundler (0.12.5) mixlib-cli (>= 1.4, < 3.0) mixlib-shellout (>= 2.0, < 4.0) ast (2.4.0) @@ -124,20 +124,6 @@ GEM debug_inspector (>= 0.0.1) builder (3.2.3) byebug (11.0.1) - chef-core (0.0.3) - chef-telemetry - mixlib-log - pastel - r18n-desktop - train-core (~> 2.0, >= 2.0.12) - tty-color - tty-cursor - tty-spinner - chef-telemetry (0.1.8) - chef-config - concurrent-ruby (~> 1.0) - ffi-yajl (~> 2.2) - http (~> 2.2) chef-vault (3.4.3) chef-zero (14.0.12) ffi-yajl (~> 2.2) @@ -149,14 +135,11 @@ GEM chef-zero (~> 14.0) net-ssh coderay (1.1.2) - concurrent-ruby (1.1.5) crack (0.4.3) safe_yaml (~> 1.0.0) debug_inspector (0.0.3) diff-lcs (1.3) docile (1.3.1) - domain_name (0.5.20180417) - unf (>= 0.0.5, < 1.0.0) ed25519 (1.2.4) equatable (0.5.0) erubis (2.7.0) @@ -182,15 +165,6 @@ GEM hashie (3.6.0) highline (1.7.10) htmlentities (4.3.4) - http (2.2.2) - addressable (~> 2.3) - http-cookie (~> 1.0) - http-form_data (~> 1.0.1) - http_parser.rb (~> 0.6.0) - http-cookie (1.0.3) - domain_name (~> 0.5) - http-form_data (1.0.3) - http_parser.rb (0.6.0) httpclient (2.8.3) iniparse (1.4.4) inspec-core (4.1.4.preview) @@ -223,7 +197,7 @@ GEM jaro_winkler (1.5.2) json (2.2.0) libyajl2 (1.2.0) - license-acceptance (0.2.13) + license-acceptance (0.2.16) pastel (~> 0.7) tomlrb (~> 1.2) tty-box (~> 0.3) @@ -264,7 +238,7 @@ GEM octokit (4.14.0) sawyer (~> 0.8.0, >= 0.5.3) parallel (1.17.0) - parser (2.6.2.1) + parser (2.6.3.0) ast (~> 2.4.0) parslet (1.8.2) pastel (0.7.2) @@ -286,9 +260,6 @@ GEM binding_of_caller (>= 0.7) pry (>= 0.9.11) public_suffix (3.0.3) - r18n-core (3.2.0) - r18n-desktop (3.2.0) - r18n-core (= 3.2.0) rack (2.0.7) rainbow (3.0.0) rake (12.3.2) @@ -375,17 +346,12 @@ GEM tty-screen (~> 0.6.4) wisper (~> 2.0.0) tty-screen (0.6.5) - tty-spinner (0.9.0) - tty-cursor (~> 0.6.0) tty-table (0.10.0) equatable (~> 0.5.0) necromancer (~> 0.4.0) pastel (~> 0.7.2) strings (~> 0.1.0) tty-screen (~> 0.6.4) - unf (0.1.4) - unf_ext - unf_ext (0.0.7.6) unicode-display_width (1.4.1) unicode_utils (1.4.0) uuidtools (2.1.5) @@ -417,7 +383,7 @@ GEM win32-taskscheduler (2.0.4) ffi structured_warnings - winrm (2.3.1) + winrm (2.3.2) builder (>= 2.1.2) erubis (~> 2.7) gssapi (~> 1.2) diff --git a/chef.gemspec b/chef.gemspec index 32e12bad41..a9d9ded1cd 100644 --- a/chef.gemspec +++ b/chef.gemspec @@ -16,7 +16,7 @@ Gem::Specification.new do |s| s.required_ruby_version = ">= 2.5.0" s.add_dependency "chef-config", "= #{Chef::VERSION}" - s.add_dependency "chef-core", "~> 0.0.3" + s.add_dependency "train-core", "~> 2.0", ">= 2.0.12" s.add_dependency "mixlib-cli", ">= 1.7", "< 3.0" s.add_dependency "mixlib-log", ">= 2.0.3", "< 4.0" diff --git a/lib/chef.rb b/lib/chef.rb index c58f46debd..8869a5a890 100644 --- a/lib/chef.rb +++ b/lib/chef.rb @@ -18,18 +18,6 @@ require "chef/version" -# Ensure that this loads ahead of anything that -# might cause rubygems to hit Gem.load_yaml, including -# evaluating gemspecs. When load_yaml is invoked, -# it stubs out the YAML::Syck namespace. This causes -# r18n to break, which expects either YAML::Syck to be there -# and fully defined (particularly, the Syck::PrivateType class), -# or for it to not be there at all. -# -# When it's not - because it's a stub - r18n explodes on loading. -# Ensuring chef_core/text and r18n are loaded first prevents this. -require "chef_core/text" - require "chef/nil_argument" require "chef/mash" require "chef/exceptions" diff --git a/lib/chef/knife/bootstrap.rb b/lib/chef/knife/bootstrap.rb index daca0957d4..d46bf455e9 100644 --- a/lib/chef/knife/bootstrap.rb +++ b/lib/chef/knife/bootstrap.rb @@ -18,11 +18,6 @@ require "chef/knife" require "chef/knife/data_bag_secret_options" -require "erubis" -require "chef/knife/bootstrap/chef_vault_handler" -require "chef/knife/bootstrap/client_builder" -require "chef/util/path_helper" -require "chef/dist" class Chef class Knife @@ -387,14 +382,16 @@ class Chef attr_accessor :client_builder attr_accessor :chef_vault_handler - attr_reader :target_host + attr_reader :connection deps do + require "erubis" + require "chef/json_compat" - require "tempfile" - require "chef_core/text" # i18n and standardized error structures - require "chef_core/target_host" - require "chef_core/target_resolver" + require "chef/util/path_helper" + require "chef/knife/bootstrap/chef_vault_handler" + require "chef/knife/bootstrap/client_builder" + require "chef/knife/bootstrap/train_connector" end banner "knife bootstrap [PROTOCOL://][USER@]FQDN (options)" @@ -417,7 +414,7 @@ class Chef # # @return [String] Default bootstrap template def default_bootstrap_template - if target_host.base_os == :windows + if connection.windows? "windows-#{Chef::Dist::CLIENT}-msi" else "chef-full" @@ -482,11 +479,11 @@ class Chef end # Establish bootstrap context for template rendering. - # Requires target_host to be a live connection in order to determine + # Requires connection to be a live connection in order to determine # the correct platform. def bootstrap_context @bootstrap_context ||= - if target_host.base_os == :windows + if connection.windows? require "chef/knife/core/windows_bootstrap_context" Knife::Core::WindowsBootstrapContext.new(config, config[:run_list], Chef::Config, secret) else @@ -556,17 +553,12 @@ class Chef bootstrap_path = upload_bootstrap(content) perform_bootstrap(bootstrap_path) ensure - target_host.del_file(bootstrap_path) if target_host && bootstrap_path + connection.del_file!(bootstrap_path) if connection && bootstrap_path end def register_client # chef-vault integration must use the new client-side hawtness, otherwise to use the # new client-side hawtness, just delete your validation key. - # 2019-04-01 TODO - # TODO - should this raise if config says to use vault because json/file/item exists - # but we still have a validation key? That means we can't use the new client hawtness, - # but we also don't tell the operator that their requested vault operations - # won't be performed if chef_vault_handler.doing_chef_vault? || (Chef::Config[:validation_key] && !File.exist?(File.expand_path(Chef::Config[:validation_key]))) @@ -589,8 +581,8 @@ class Chef def perform_bootstrap(remote_bootstrap_script_path) ui.info("Bootstrapping #{ui.color(server_name, :bold)}") cmd = bootstrap_command(remote_bootstrap_script_path) - r = target_host.run_command(cmd) do |data| - ui.msg("#{ui.color(" [#{target_host.hostname}]", :cyan)} #{data}") + r = connection.run_command(cmd) do |data| + ui.msg("#{ui.color(" [#{connection.hostname}]", :cyan)} #{data}") end if r.exit_status != 0 ui.error("The following error occurred on #{server_name}:") @@ -603,24 +595,13 @@ class Chef ui.info("Connecting to #{ui.color(server_name, :bold)}") opts = connection_opts.dup do_connect(opts) - rescue => e - # Ugh. TODO: Train raises a Train::Transports::SSHFailed for a number of different errors. chef_core makes that - # a more general ConnectionFailed, with an error code based on the specific error text/reason provided from trainm. - # This means we have to look three layers into the exception to find out what actually happened instead of just - # looking at the exception type - # - # It doesn't help to provide our own error if it does't let the caller know what they need to identify the problem. - # Let's update chef_core to be a bit smarter about resolving the errors to an appropriate exception type - # (eg ChefCore::ConnectionFailed::AuthError or similar) that will work across protocols, instead of just a single - # ConnectionFailure type - # - - if e.cause && e.cause.cause && e.cause.cause.class == Net::SSH::AuthenticationFailed - if opts[:password] + rescue Train::Error => e + if e.cause && e.cause.class == Net::SSH::AuthenticationFailed + if connection.password_auth? raise else ui.warn("Failed to authenticate #{opts[:user]} to #{server_name} - trying password auth") - password = ui.ask("Enter password for #{opts[:user]}@#{server_name} - trying password auth") do |q| + password = ui.ask("Enter password for #{opts[:user]}@#{server_name}.") do |q| q.echo = false end end @@ -631,6 +612,9 @@ class Chef end end + # TODO - maybe remove the footgun detection this was built on. + # url values override CLI flags, if you provide both + # we'll use the one that you gave in the URL. def connection_protocol return @connection_protocol if @connection_protocol from_url = host_descriptor =~ /^(.*):\/\// ? $1 : nil @@ -640,14 +624,8 @@ class Chef end def do_connect(conn_options) - # Resolve the given host name to a TargetHost instance. We will limit - # the number of hosts to 1 (effectivly eliminating wildcard support) since - # we only support running bootstrap against one host at a time. - resolver = ChefCore::TargetResolver.new(host_descriptor, connection_protocol, - conn_options, max_expanded_targets: 1) - @target_host = resolver.targets.first - target_host.connect! - target_host + @connection = TrainConnector.new(host_descriptor, connection_protocol, conn_options) + connection.connect! end # Fail if both first_boot_attributes and first_boot_attributes_from_file @@ -664,7 +642,7 @@ class Chef # TODO test for this method # TODO check that the protoocol is valid. def validate_winrm_transport_opts! - return true if connection_protocol != "winrm" + return true unless winrm? if Chef::Config[:validation_key] && !File.exist?(File.expand_path(Chef::Config[:validation_key])) if config_value(:winrm_auth_method) == "plaintext" && @@ -739,7 +717,7 @@ class Chef end def winrm_warn_no_ssl_verification - return if connection_protocol != "winrm" + return unless winrm? # REVIEWER NOTE # The original check from knife plugin did not include winrm_ssl_peer_fingerprint @@ -768,13 +746,8 @@ class Chef end end - # - - # Create a configuration hash for TargetHost to connect - # to the remote host via Train. - # # @return a configuration hash suitable for connecting to the remote - # host via TargetHost. + # host via train def connection_opts return @connection_opts unless @connection_opts.nil? @connection_opts = {} @@ -788,9 +761,16 @@ class Chef @connection_opts end + def winrm? + connection_protocol == "winrm" + end + + def ssh? + connection_protocol == "ssh" + end + # Common configuration for all protocols def base_opts - # port = config_value(:connection_port, knife_key_for_protocol(connection_protocol, :port)) user = config_value(:connection_user, @@ -807,10 +787,9 @@ class Chef end def host_verify_opts - case connection_protocol - when "winrm" + if winrm? { self_signed: config_value(:winrm_no_verify_cert) === true } - when "ssh" + elsif ssh? # Fall back to the old knife config key name for back compat. { verify_host_key: config_value(:ssh_verify_host_key, :host_key_verify, true) === true } @@ -959,16 +938,16 @@ class Chef end def upload_bootstrap(content) - script_name = target_host.base_os == :windows ? "bootstrap.bat" : "bootstrap.sh" - remote_path = target_host.normalize_path(File.join(target_host.temp_dir, script_name)) - target_host.save_as_remote_file(content, remote_path) + script_name = connection.windows? ? "bootstrap.bat" : "bootstrap.sh" + remote_path = connection.normalize_path(File.join(connection.temp_dir, script_name)) + connection.upload_file_content!(content, remote_path) remote_path end # build the command string for bootrapping # @return String def bootstrap_command(remote_path) - if target_host.base_os == :windows + if connection.windows? "cmd.exe /C #{remote_path}" else "sh #{remote_path}" @@ -977,8 +956,9 @@ class Chef # To avoid cluttering the CLI options, some flags (such as port and user) # are shared between protocols. However, there is still a need to allow the operator - # to specify defaults separately, since they may not be the same values for different protocols. - # + # to specify defaults separately, since they may not be the same values for different + # protocols. + # These keys are available in Chef::Config, and are prefixed with the protocol name. # For example, :user CLI option will map to :winrm_user and :ssh_user Chef::Config keys, # based on the connection protocol in use. diff --git a/lib/chef/knife/bootstrap/train_connector.rb b/lib/chef/knife/bootstrap/train_connector.rb new file mode 100644 index 0000000000..5230d6638c --- /dev/null +++ b/lib/chef/knife/bootstrap/train_connector.rb @@ -0,0 +1,286 @@ +# Copyright:: Copyright (c) 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 "train" +require "tempfile" +require "uri" + +class Chef + class Knife + class Bootstrap < Knife + class TrainConnector + SSH_CONFIG_OVERRIDE_KEYS = [:user, :port, :proxy].freeze + + MKTEMP_WIN_COMMAND = <<~EOM.freeze + $parent = [System.IO.Path]::GetTempPath(); + [string] $name = [System.Guid]::NewGuid(); + $tmp = New-Item -ItemType Directory -Path; + (Join-Path $parent $name); + $tmp.FullName + EOM + + MKTEMP_NIX_COMMAND = "bash -c 'd=$(mktemp -d ${TMPDIR:-/tmp}/chef_XXXXXX); echo $d'".freeze + + def initialize(host_url, default_transport, opts) + uri_opts = opts_from_uri(host_url) + uri_opts[:backend] ||= @default_transport + @transport_type = uri_opts[:backend] + + # opts in the URI will override user-provided options + @config = transport_config(host_url, opts.merge(uri_opts)) + end + + # Because creating a valid train connection for testing is a two-step process in which + # we need to connect before mocking config, + # we expose test_instance as a way for tests to create actual instances + # but ensure that they don't connect to any back end. + def self.test_instance(url, protocol: "ssh", + family: "unknown", name: "unknown", + release: "unknown", arch: "x86_64", + opts: {}) + # Specifying sudo: false ensures that attempted operations + # don't fail because the mock platform doesn't support sudo + tc = TrainConnector.new(url, protocol, { sudo: false }.merge(opts)) + tc.connect! + tc.connection.mock_os( + family: family, + name: name, + release: release, + arch: arch + ) + tc + end + + def connect! + # Force connection to establish + connection.wait_until_ready + true + end + + def hostname + @config[:host] + end + + def password_auth? + @config.key? :password + end + + # True if we're connected to a linux host + def linux? + connection.platform.linux? + end + + # True if we're connected to a unix host. + # NOTE: this is always true + # for a linux host because train classifies + # linux as a unix + def unix? + connection.platform.unix? + end + + # True if we're connected to a windows host + def windows? + connection.platform.windows? + end + + def winrm? + @transport_type == "winrm" + end + + def ssh? + @transport_type == "ssh" + end + + # Creates a temporary directory on the remote host if it + # hasn't already. Caches directory location. + # + # Returns the path on the remote host. + def temp_dir + cmd = windows? ? MKTEMP_WIN_COMMAND : MKTEMP_NIX_COMMAND + @tmpdir ||= begin + res = run_command!(cmd) + dir = res.stdout.chomp.strip + unless windows? + # Ensure that dir has the correct owner. We are possibly + # running with sudo right now - so this directory would be owned by root. + # File upload is performed over SCP as the current logged-in user, + # so we'll set ownership to ensure that works. + run_command!("chown #{@config[:user]} '#{dir}'") + end + dir + end + end + + def upload_file!(local_path, remote_path) + connection.upload(local_path, remote_path) + end + + def upload_file_content!(content, remote_path) + t = Tempfile.new("chef-content") + t << content + t.close + upload_file!(t.path, remote_path) + ensure + t.close + t.unlink + end + + def del_file!(path) + if windows? + run_command!("If (Test-Path \"#{path}\") { Remove-Item -Force -Path \"#{path}\" }") + else + run_command!("rm -f \"#{path}\"") + end + end + + # normalizes path across OS's + def normalize_path(path) + path.tr("\\", "/") + end + + def run_command(command, &data_handler) + connection.run_command(command, &data_handler) + end + + def run_command!(command, &data_handler) + result = run_command(command, &data_handler) + if result.exit_status != 0 + raise RemoteExecutionFailed.new(hostname, command, result) + end + result + end + + def connection + @connection ||= begin + Train.validate_backend(@config) + train = Train.create(@transport_type, @config) + train.connection + end + end + + private + + # For a given url and set of options, create a config + # hash suitable for passing into train. + def transport_config(host_url, opts_in) + opts = { target: host_url, + sudo: opts_in[:sudo] === false ? false : true, + www_form_encoded_password: true, + key_files: opts_in[:key_files], + non_interactive: true, # Prevent password prompts + transport_retries: 2, + transport_retry_sleep: 1, + logger: opts_in[:logger], + backend: @transport_type } + + # Base opts are those provided by the caller directly + opts.merge!(opts_from_caller(opts, opts_in)) + + # WinRM has some additional computed options + opts.merge!(opts_inferred_from_winrm(opts, opts_in)) + + # Now that everything is populated, fill in anything left + # from user ssh config that may be present + opts.merge!(missing_opts_from_ssh_config(opts, opts_in)) + + Train.target_config(opts) + end + + # Some winrm options are inferred based on other options. + # Return a hash of winrm options based on configuration already built. + def opts_inferred_from_winrm(config, opts_in) + return {} unless winrm? + opts_out = {} + + if opts_in[:ssl] + opts_out[:ssl] = true + opts_out[:self_signed] = opts_in[:self_signed] || false + end + + # See note here: https://github.com/mwrock/WinRM#example + if %w{ssl plaintext}.include?(opts_in[:winrm_auth_method]) + opts_out[:winrm_disable_sspi] = true + end + opts_out + end + + # Returns a hash containing valid options for the current + # transport protocol that are not already present in config + def opts_from_caller(config, opts_in) + # Train.options gives us the supported config options for the + # backend provider (ssh, winrm). We'll use that + # to filter out options that don't belong + # to the transport type we're using. + valid_opts = Train.options(config[:backend]) + opts_in.select do |key, _v| + valid_opts.key?(key) && !config.key?(key) + end + end + + # Extract any of username/password/host/port/transport + # that are in the URI and return them as a config has + def opts_from_uri(uri) + # Train.unpack_target_from_uri only works for complete URIs in + # form of proto://[user[:pass]@]host[:port]/ + # So we'll add the protocol prefix if it's not supplied. + uri_to_check = if URI.regexp.match(uri) + uri + else + "#{@transport_type}://#{uri}" + end + + Train.unpack_target_from_uri(uri_to_check) + end + + # This returns a hash that consists of settings + # populated from SSH configuration that are not already present + # in the configuration passed in. + # This is necessary because train will default these values + # itself - causing SSH config data to be ignored + def missing_opts_from_ssh_config(config, opts_in) + return {} unless ssh? + host_cfg = ssh_config_for_host(config[:host]) + opts_out = {} + opts_in.each do |key, _value| + if SSH_CONFIG_OVERRIDE_KEYS.include?(key) && !config.key?(key) + opts_out[key] = host_cfg[key] + end + end + opts_out + end + + # Having this as a method makes it easier to mock + # SSH Config for testing. + def ssh_config_for_host(host) + require "net/ssh" + Net::SSH::Config.for(host) + end + end + + class RemoteExecutionFailed < StandardError + attr_reader :exit_status, :command, :hostname, :stdout, :stderr + def initialize(hostname, command, result) + @hostname = hostname + @exit_status = result.exit_status + @stderr = result.stderr + @stdout = result.stdout + end + end + + end + end +end diff --git a/spec/support/shared/integration/integration_helper.rb b/spec/support/shared/integration/integration_helper.rb index b6851f2d0e..5fc9de4de7 100644 --- a/spec/support/shared/integration/integration_helper.rb +++ b/spec/support/shared/integration/integration_helper.rb @@ -19,7 +19,6 @@ require "tmpdir" require "fileutils" -require "chef_core/text" require "chef/config" require "chef/json_compat" require "chef/server_api" diff --git a/spec/unit/knife/bootstrap/train_connector_spec.rb b/spec/unit/knife/bootstrap/train_connector_spec.rb new file mode 100644 index 0000000000..08bf21dd42 --- /dev/null +++ b/spec/unit/knife/bootstrap/train_connector_spec.rb @@ -0,0 +1,155 @@ +# +# Copyright:: Copyright (c) 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" +require "ostruct" +require "chef/knife/bootstrap/train_connector" + +describe Chef::Knife::Bootstrap::TrainConnector do + let(:protocol) { "mock" } + let(:family) { "unknown" } + let(:release) { "unknown" } # version + let(:name) { "unknown" } + let(:arch) { "x86_64" } + let(:host_url) { "mock://user1@example.com" } + let(:opts) { {} } + subject do + # Create a valid TargetHost with the backend stubbed out. + Chef::Knife::Bootstrap::TrainConnector.test_instance(host_url, + protocol: protocol, + family: family, + name: name, + release: release, + arch: arch, + opts: opts) + end + + context "connect!" do + end + + describe "platform helpers" do + context "on linux" do + let(:family) { "debian" } + let(:name) { "ubuntu" } + it "reports that it is linux and unix, because that is how train classifies it" do + expect(subject.unix?).to eq true + expect(subject.linux?).to eq true + expect(subject.windows?).to eq false + end + end + context "on unix" do + let(:family) { "os" } + let(:name) { "mac_os_x" } + it "reports only a unix OS" do + expect(subject.unix?).to eq true + expect(subject.linux?).to eq false + expect(subject.windows?).to eq false + end + end + context "on windows" do + let(:family) { "windows" } + let(:name) { "windows" } + it "reports only a windows OS" do + expect(subject.unix?).to eq false + expect(subject.linux?).to eq false + expect(subject.windows?).to eq true + end + end + end + + describe "#connect!" do + it "establishes the connection to the remote host by waiting for it" do + expect(subject.connection).to receive(:wait_until_ready) + subject.connect! + end + end + + describe "#temp_dir" do + context "under windows" do + let(:family) { "windows" } + let(:name) { "windows" } + + it "uses the windows command to create the temp dir" do + expected_command = Chef::Knife::Bootstrap::TrainConnector::MKTEMP_WIN_COMMAND + expect(subject).to receive(:run_command!).with(expected_command) + .and_return double("result", stdout: "C:/a/path") + expect(subject.temp_dir).to eq "C:/a/path" + end + + end + context "under linux and unix-like" do + let(:family) { "debian" } + let(:name) { "ubuntu" } + it "uses the *nix command to create the temp dir and sets ownership to logged-in user" do + expected_command = Chef::Knife::Bootstrap::TrainConnector::MKTEMP_NIX_COMMAND + expect(subject).to receive(:run_command!).with(expected_command) + .and_return double("result", stdout: "/a/path") + expect(subject).to receive(:run_command!).with("chown user1 '/a/path'") + expect(subject.temp_dir).to eq "/a/path" + end + + end + end + context "#upload_file_content!" do + it "creates a local file with expected content and uploads it" do + expect(subject).to receive(:upload_file!) do |local_path, remote_path| + expect(File.read(local_path)).to eq "test data" + expect(remote_path).to eq "/target/path" + end + subject.upload_file_content!("test data", "/target/path") + end + end + + context "del_file" do + context "on windows" do + let(:family) { "windows" } + let(:name) { "windows" } + it "deletes the file with a windows command" do + expect(subject).to receive(:run_command!) do |cmd, &_handler| + expect(cmd).to match(/Test-Path "deleteme\.txt".*/) + end + subject.del_file!("deleteme.txt") + end + end + context "on unix-like" do + let(:family) { "debian" } + let(:name) { "ubuntu" } + it "deletes the file with a windows command" do + expect(subject).to receive(:run_command!) do |cmd, &_handler| + expect(cmd).to match(/rm -f "deleteme\.txt".*/) + end + subject.del_file!("deleteme.txt") + end + end + end + + context "#run_command!" do + it "raises a RemoteExecutionFailed when the remote execution failed" do + command_result = double("results", stdout: "", stderr: "failed", exit_status: 1) + expect(subject).to receive(:run_command).and_return command_result + + expect { subject.run_command!("test") }.to raise_error do |e| + expect(e.hostname).to eq subject.hostname + expect(e.class).to eq Chef::Knife::Bootstrap::RemoteExecutionFailed + expect(e.stderr).to eq "failed" + expect(e.stdout).to eq "" + expect(e.exit_status).to eq 1 + end + end + end + +end diff --git a/spec/unit/knife/bootstrap_spec.rb b/spec/unit/knife/bootstrap_spec.rb index f54c8ac1d6..ce590fc9ee 100644 --- a/spec/unit/knife/bootstrap_spec.rb +++ b/spec/unit/knife/bootstrap_spec.rb @@ -25,8 +25,17 @@ describe Chef::Knife::Bootstrap do let(:bootstrap_template) { nil } let(:stderr) { StringIO.new } let(:bootstrap_cli_options) { [ ] } - let(:base_os) { :linux } - let(:target_host) { double("TargetHost") } + let(:linux_test) { true } + let(:windows_test) { false } + let(:linux_test) { false } + let(:unix_test) { false } + let(:ssh_test) { false } + + let(:connection) do + double("TrainConnector", + windows?: windows_test, + linux?: linux_test, + unix?: unix_test) end let(:knife) do Chef::Log.logger = Logger.new(StringIO.new) @@ -35,15 +44,11 @@ describe Chef::Knife::Bootstrap do k = Chef::Knife::Bootstrap.new(bootstrap_cli_options) allow(k.ui).to receive(:stderr).and_return(stderr) allow(k).to receive(:encryption_secret_provided_ignore_encrypt_flag?).and_return(false) - allow(k).to receive(:target_host).and_return target_host + allow(k).to receive(:connection).and_return connection k.merge_configs k end - before do - allow(target_host).to receive(:base_os).and_return base_os - end - context "#bootstrap_template" do it "should default to chef-full" do expect(knife.bootstrap_template).to be_a_kind_of(String) @@ -320,7 +325,7 @@ describe Chef::Knife::Bootstrap do subject(:knife) do k = described_class.new Chef::Config[:knife][:bootstrap_template] = template_file - allow(k).to receive(:target_host).and_return target_host + allow(k).to receive(:connection).and_return connection k.parse_options(options) k.merge_configs k @@ -1578,7 +1583,7 @@ describe Chef::Knife::Bootstrap do expect(knife).to receive(:render_template).and_return "content" expect(knife).to receive(:upload_bootstrap).with("content").and_return "/remote/path.sh" expect(knife).to receive(:perform_bootstrap).with("/remote/path.sh") - expect(target_host).to receive(:del_file) # Make sure cleanup happens + expect(connection).to receive(:del_file!) # Make sure cleanup happens knife.run @@ -1687,14 +1692,14 @@ describe Chef::Knife::Bootstrap do let(:result_mock) { double("result", exit_status: exit_status, stderr: "A message") } before do - allow(target_host).to receive(:hostname).and_return "testhost" + allow(connection).to receive(:hostname).and_return "testhost" end it "runs the remote script and logs the output" do expect(knife.ui).to receive(:info).with(/Bootstrapping.*/) expect(knife).to receive(:bootstrap_command) .with("/path.sh") .and_return("sh /path.sh") - expect(target_host) + expect(connection) .to receive(:run_command) .with("sh /path.sh") .and_yield("output here") @@ -1710,7 +1715,7 @@ describe Chef::Knife::Bootstrap do expect(knife).to receive(:bootstrap_command) .with("/path.sh") .and_return("sh /path.sh") - expect(target_host).to receive(:run_command).with("sh /path.sh").and_return result_mock + expect(connection).to receive(:run_command).with("sh /path.sh").and_return result_mock expect { knife.perform_bootstrap("/path.sh") }.to raise_error(SystemExit) end end @@ -1738,13 +1743,11 @@ describe Chef::Knife::Bootstrap do context "when an auth failure occurs" do let(:expected_error) do - # TODO This is awkward and ugly. Requires some refactor of chef_core/error - # to make it not so. See comment in rescue block of connect! for details. - e = RuntimeError.new - interim = RuntimeError.new + e = Train::Error.new actual = Net::SSH::AuthenticationFailed.new - allow(interim).to receive(:cause).and_return(actual) - allow(e).to receive(:cause).and_return(interim) + # Simulate train's nested error - they wrap + # ssh/network errors in a TrainError. + allow(e).to receive(:cause).and_return(actual) e end @@ -1754,7 +1757,7 @@ describe Chef::Knife::Bootstrap do context "and password auth was used" do before do - knife.config[:connection_password] = "tryme" + allow(connection).to receive(:password_auth?).and_return true end it "re-raises the error so as not to resubmit the same failing password" do @@ -1765,8 +1768,8 @@ describe Chef::Knife::Bootstrap do context "and password auth was not used" do before do - knife.config.delete :connection_password - allow(target_host).to receive(:user).and_return "testuser" + allow(connection).to receive(:password_auth?).and_return false + allow(connection).to receive(:user).and_return "testuser" end it "warns, prompts for password, then reconnects with a password-enabled configuration using the new password" do @@ -1793,7 +1796,7 @@ describe Chef::Knife::Bootstrap do describe "#bootstrap_context" do context "under Windows" do - let(:base_os) { :windows } + let(:windows_test) { true } it "creates a WindowsBootstrapContext" do require "chef/knife/core/windows_bootstrap_context" expect(knife.bootstrap_context.class).to eq Chef::Knife::Core::WindowsBootstrapContext @@ -1801,7 +1804,7 @@ describe Chef::Knife::Bootstrap do end context "under linux" do - let(:base_os) { :linux } + let(:linux_test) { true } it "creates a BootstrapContext" do require "chef/knife/core/bootstrap_context" expect(knife.bootstrap_context.class).to eq Chef::Knife::Core::BootstrapContext @@ -1841,25 +1844,25 @@ describe Chef::Knife::Bootstrap do describe "#upload_bootstrap" do before do - allow(target_host).to receive(:temp_dir).and_return(temp_dir) - allow(target_host).to receive(:normalize_path) { |a| a } + allow(connection).to receive(:temp_dir).and_return(temp_dir) + allow(connection).to receive(:normalize_path) { |a| a } end let(:content) { "bootstrap script content" } context "under Windows" do - let(:base_os) { :windows } + let(:windows_test) { true } let(:temp_dir) { "C:/Temp/bootstrap" } - it "creates a bat file in the temp dir provided by target_host, using given content" do - expect(target_host).to receive(:save_as_remote_file).with(content, "C:/Temp/bootstrap/bootstrap.bat") + it "creates a bat file in the temp dir provided by connection, using given content" do + expect(connection).to receive(:upload_file_content!).with(content, "C:/Temp/bootstrap/bootstrap.bat") expect(knife.upload_bootstrap(content)).to eq "C:/Temp/bootstrap/bootstrap.bat" end end context "under Linux" do - let(:base_os) { :linux } + let(:linux_test) { true } let(:temp_dir) { "/tmp/bootstrap" } - it "creates a 'sh file in the temp dir provided by target_host, using given content" do - expect(target_host).to receive(:save_as_remote_file).with(content, "/tmp/bootstrap/bootstrap.sh") + it "creates a 'sh file in the temp dir provided by connection, using given content" do + expect(connection).to receive(:upload_file_content!).with(content, "/tmp/bootstrap/bootstrap.sh") expect(knife.upload_bootstrap(content)).to eq "/tmp/bootstrap/bootstrap.sh" end end @@ -1867,14 +1870,14 @@ describe Chef::Knife::Bootstrap do describe "#bootstrap_command" do context "under Windows" do - let(:base_os) { :windows } + let(:windows_test) { true } it "prefixes the command to run under cmd.exe" do expect(knife.bootstrap_command("autoexec.bat")).to eq "cmd.exe /C autoexec.bat" end end context "under Linux" do - let(:base_os) { :linux } + let(:linux_test) { true } it "prefixes the command to run under sh" do expect(knife.bootstrap_command("bootstrap")).to eq "sh bootstrap" end @@ -1883,14 +1886,14 @@ describe Chef::Knife::Bootstrap do describe "#default_bootstrap_template" do context "under Windows" do - let(:base_os) { :windows } + let(:windows_test) { true } it "is windows-chef-client-msi" do expect(knife.default_bootstrap_template).to eq "windows-chef-client-msi" end end context "under Linux" do - let(:base_os) { :linux } + let(:linux_test) { true } it "is chef-full" do expect(knife.default_bootstrap_template).to eq "chef-full" end @@ -1899,15 +1902,15 @@ describe Chef::Knife::Bootstrap do describe "#do_connect" do let(:host_descriptor) { "example.com" } - let(:target_host) { double("TargetHost") } - let(:resolver_mock) { double("TargetResolver", targets: [ target_host ]) } + let(:connection) { double("TrainConnector") } + let(:connector_mock) { double("TargetResolver", targets: [ connection ]) } before do allow(knife).to receive(:host_descriptor).and_return host_descriptor end - it "resolves the target and connects it" do - expect(ChefCore::TargetResolver).to receive(:new).and_return resolver_mock - expect(target_host).to receive(:connect!) + it "creates a TrainConnector and connects it" do + expect(Chef::Knife::Bootstrap::TrainConnector).to receive(:new).and_return connection + expect(connection).to receive(:connect!) knife.do_connect({}) end end |