summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTim Smith <tsmith@chef.io>2019-04-29 16:03:24 -0700
committerGitHub <noreply@github.com>2019-04-29 16:03:24 -0700
commit0acc51c23e8149e6d6fe675e801c95b1a4e77e84 (patch)
tree184fd2199c795a223f42d2bf0c87adfc56985ca0
parent945b9f6636bb3236999ca43f313113bf74045d82 (diff)
parente583616cd435be79142b5dfb676cfc9003198474 (diff)
downloadchef-0acc51c23e8149e6d6fe675e801c95b1a4e77e84.tar.gz
Merge pull request #8419 from chef/mp/train-alone
Implement bootstrap directly with train
-rw-r--r--Gemfile.lock48
-rw-r--r--chef.gemspec2
-rw-r--r--lib/chef.rb12
-rw-r--r--lib/chef/knife/bootstrap.rb104
-rw-r--r--lib/chef/knife/bootstrap/train_connector.rb286
-rw-r--r--spec/support/shared/integration/integration_helper.rb1
-rw-r--r--spec/unit/knife/bootstrap/train_connector_spec.rb155
-rw-r--r--spec/unit/knife/bootstrap_spec.rb83
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