diff options
author | Marc A. Paradise <marc.paradise@gmail.com> | 2019-02-20 10:55:01 -0500 |
---|---|---|
committer | Marc A. Paradise <marc.paradise@gmail.com> | 2019-04-24 13:25:58 -0400 |
commit | df96e7420dd1c258c794d2181d911add3eea8c47 (patch) | |
tree | e09cf59c7f3f5b3291846122a18e9c4f08e5f2f0 | |
parent | f492fe53eac1b74a0d184f0e9cf7412b70770e29 (diff) | |
download | chef-df96e7420dd1c258c794d2181d911add3eea8c47.tar.gz |
Bootstrap via chef_core
Make bootstrap use train via chef_core/TargetHost
This commit implements usage of TargetHost instead of
knife_ssh. TargetHost is a platform-independent representation
of a Train connection. It abstracts common operations
(such as file upload/download, permissions, temp directories,
command executation, etc) and connection error handling.
Moving to TargetHost and train gives us the ability to
execute commands on the bootstrap target; instead of
running sh -c 'long-command-string-containing-secrets', we'll
now upload the bootstrap script to a temporary directory on the
bootstrap target and execute it there.
Incorporating WinRM support directly (allowing core support
for Windows bootstraps, without the knife-windows plugin)
will follow.
THis also improves unit test coverage of the bootstrap module,
and clarifies functional versus behavior tests in that space
Signed-off-by: Marc A. Paradise <marc.paradise@gmail.com>
-rw-r--r-- | Gemfile | 10 | ||||
-rw-r--r-- | lib/chef.rb | 4 | ||||
-rw-r--r-- | lib/chef/knife/bootstrap.rb | 727 | ||||
-rw-r--r-- | lib/chef/knife/bootstrap/options.rb | 358 | ||||
-rw-r--r-- | lib/chef/knife/bootstrap/templates/windows-chef-client-msi.erb | 271 | ||||
-rw-r--r-- | lib/chef/knife/core/bootstrap_context.rb | 2 | ||||
-rw-r--r-- | lib/chef/knife/core/ui.rb | 7 | ||||
-rw-r--r-- | lib/chef/knife/core/windows_bootstrap_context.rb | 412 | ||||
-rw-r--r-- | spec/unit/knife/bootstrap_spec.rb | 1583 |
9 files changed, 2877 insertions, 497 deletions
@@ -13,6 +13,16 @@ gem "ohai", git: "https://github.com/chef/ohai.git", branch: "master" gem "chef-config", path: File.expand_path("../chef-config", __FILE__) if File.exist?(File.expand_path("../chef-config", __FILE__)) gem "cheffish", "~> 14" +# The chef_core gems are sourced from git. +# gem "chef_core", git: "https://github.com/chef/chef_core", branch: "chef-core-split" +# gem "chef_core-actions", git: "https://github.com/chef/chef_core-actions", branch: "chef-core-split" +# gem "chef_core-cliux", git: "https://github.com/chef/chef_core-cliux", branch: "chef-core-split" +# Temporary for testing: +#gem "train", path: "../train" +# gem "chef_core", path: "../chef_core" +# gem "chef_core-actions", path: "../chef_core-actions" +# gem "chef_core-cliux", path: "../chef_core-cliux" + group(:omnibus_package) do gem "appbundler" gem "rb-readline" diff --git a/lib/chef.rb b/lib/chef.rb index 3d6b783253..ccfb57764d 100644 --- a/lib/chef.rb +++ b/lib/chef.rb @@ -16,7 +16,11 @@ # limitations under the License. # + require "chef/version" + +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 b9e09a15ba..aa5d1f502a 100644 --- a/lib/chef/knife/bootstrap.rb +++ b/lib/chef/knife/bootstrap.rb @@ -1,6 +1,6 @@ # # Author:: Adam Jacob (<adam@chef.io>) -# Copyright:: Copyright 2010-2016, Chef Software Inc. +# Copyright:: Copyright 2010-2019, Chef Software Inc. # License:: Apache License, Version 2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -22,229 +22,31 @@ require "erubis" require "chef/knife/bootstrap/chef_vault_handler" require "chef/knife/bootstrap/client_builder" require "chef/util/path_helper" +require "chef/knife/bootstrap/options" class Chef class Knife class Bootstrap < Knife include DataBagSecretOptions + # Command line flags and options for bootstrap - there's a large number of them + # so we'll keep this file a little smaller by splitting them out. + include Bootstrap::Options + + SUPPORTED_CONNECTION_PROTOCOLS = %w{ssh winrm} attr_accessor :client_builder attr_accessor :chef_vault_handler + attr_reader :target_host deps do - require "chef/knife/core/bootstrap_context" require "chef/json_compat" require "tempfile" - require "highline" - require "net/ssh" - require "net/ssh/multi" - require "chef/knife/ssh" - Chef::Knife::Ssh.load_deps - end - - banner "knife bootstrap [SSH_USER@]FQDN (options)" - - option :ssh_user, - short: "-x USERNAME", - long: "--ssh-user USERNAME", - description: "The ssh username", - default: "root" - - option :ssh_password, - short: "-P PASSWORD", - long: "--ssh-password PASSWORD", - description: "The ssh password" - - option :ssh_port, - short: "-p PORT", - long: "--ssh-port PORT", - description: "The ssh port", - proc: Proc.new { |key| Chef::Config[:knife][:ssh_port] = key } - - option :ssh_gateway, - short: "-G GATEWAY", - long: "--ssh-gateway GATEWAY", - description: "The ssh gateway", - proc: Proc.new { |key| Chef::Config[:knife][:ssh_gateway] = key } - - option :ssh_gateway_identity, - long: "--ssh-gateway-identity SSH_GATEWAY_IDENTITY", - description: "The SSH identity file used for gateway authentication", - proc: Proc.new { |key| Chef::Config[:knife][:ssh_gateway_identity] = key } - - option :forward_agent, - short: "-A", - long: "--forward-agent", - description: "Enable SSH agent forwarding", - boolean: true - - option :ssh_identity_file, - short: "-i IDENTITY_FILE", - long: "--ssh-identity-file IDENTITY_FILE", - description: "The SSH identity file used for authentication" - - option :chef_node_name, - short: "-N NAME", - long: "--node-name NAME", - description: "The Chef node name for your new node" - - option :prerelease, - long: "--prerelease", - description: "Install the pre-release chef gems" - - option :bootstrap_version, - long: "--bootstrap-version VERSION", - description: "The version of Chef to install", - proc: lambda { |v| Chef::Config[:knife][:bootstrap_version] = v } - - option :bootstrap_proxy, - long: "--bootstrap-proxy PROXY_URL", - description: "The proxy server for the node being bootstrapped", - proc: Proc.new { |p| Chef::Config[:knife][:bootstrap_proxy] = p } - - option :bootstrap_proxy_user, - long: "--bootstrap-proxy-user PROXY_USER", - description: "The proxy authentication username for the node being bootstrapped" - - option :bootstrap_proxy_pass, - long: "--bootstrap-proxy-pass PROXY_PASS", - description: "The proxy authentication password for the node being bootstrapped" - - option :bootstrap_no_proxy, - long: "--bootstrap-no-proxy [NO_PROXY_URL|NO_PROXY_IP]", - description: "Do not proxy locations for the node being bootstrapped; this option is used internally by Opscode", - proc: Proc.new { |np| Chef::Config[:knife][:bootstrap_no_proxy] = np } - - option :bootstrap_template, - short: "-t TEMPLATE", - long: "--bootstrap-template TEMPLATE", - description: "Bootstrap Chef using a built-in or custom template. Set to the full path of an erb template or use one of the built-in templates." - - option :use_sudo, - long: "--sudo", - description: "Execute the bootstrap via sudo", - boolean: true - - option :preserve_home, - long: "--sudo-preserve-home", - description: "Preserve non-root user HOME environment variable with sudo", - boolean: true - - option :use_sudo_password, - long: "--use-sudo-password", - description: "Execute the bootstrap via sudo with password", - boolean: false - - option :run_list, - short: "-r RUN_LIST", - long: "--run-list RUN_LIST", - description: "Comma separated list of roles/recipes to apply", - proc: lambda { |o| o.split(/[\s,]+/) }, - default: [] - - option :policy_name, - long: "--policy-name POLICY_NAME", - description: "Policyfile name to use (--policy-group must also be given)", - default: nil - - option :policy_group, - long: "--policy-group POLICY_GROUP", - description: "Policy group name to use (--policy-name must also be given)", - default: nil - - option :tags, - long: "--tags TAGS", - description: "Comma separated list of tags to apply to the node", - proc: lambda { |o| o.split(/[\s,]+/) }, - default: [] - - option :first_boot_attributes, - short: "-j JSON_ATTRIBS", - long: "--json-attributes", - description: "A JSON string to be added to the first run of chef-client", - proc: lambda { |o| Chef::JSONCompat.parse(o) }, - default: nil - - option :first_boot_attributes_from_file, - long: "--json-attribute-file FILE", - description: "A JSON file to be used to the first run of chef-client", - proc: lambda { |o| Chef::JSONCompat.parse(File.read(o)) }, - default: nil - - option :host_key_verify, - long: "--[no-]host-key-verify", - description: "Verify host key, enabled by default.", - boolean: true, - default: true - - option :hint, - long: "--hint HINT_NAME[=HINT_FILE]", - description: "Specify Ohai Hint to be set on the bootstrap target. Use multiple --hint options to specify multiple hints.", - proc: Proc.new { |h| - Chef::Config[:knife][:hints] ||= Hash.new - name, path = h.split("=") - Chef::Config[:knife][:hints][name] = path ? Chef::JSONCompat.parse(::File.read(path)) : Hash.new - } - - option :bootstrap_url, - long: "--bootstrap-url URL", - description: "URL to a custom installation script", - proc: Proc.new { |u| Chef::Config[:knife][:bootstrap_url] = u } - - option :bootstrap_install_command, - long: "--bootstrap-install-command COMMANDS", - description: "Custom command to install chef-client", - proc: Proc.new { |ic| Chef::Config[:knife][:bootstrap_install_command] = ic } - - option :bootstrap_preinstall_command, - long: "--bootstrap-preinstall-command COMMANDS", - description: "Custom commands to run before installing chef-client", - proc: Proc.new { |preic| Chef::Config[:knife][:bootstrap_preinstall_command] = preic } - - option :bootstrap_wget_options, - long: "--bootstrap-wget-options OPTIONS", - description: "Add options to wget when installing chef-client", - proc: Proc.new { |wo| Chef::Config[:knife][:bootstrap_wget_options] = wo } - - option :bootstrap_curl_options, - long: "--bootstrap-curl-options OPTIONS", - description: "Add options to curl when install chef-client", - proc: Proc.new { |co| Chef::Config[:knife][:bootstrap_curl_options] = co } - - option :node_ssl_verify_mode, - long: "--node-ssl-verify-mode [peer|none]", - description: "Whether or not to verify the SSL cert for all HTTPS requests.", - proc: Proc.new { |v| - valid_values = %w{none peer} - unless valid_values.include?(v) - raise "Invalid value '#{v}' for --node-ssl-verify-mode. Valid values are: #{valid_values.join(", ")}" - end - v - } + require "chef_core/text" # i18n and standardized error structures + require "chef_core/target_host" + require "chef_core/target_resolver" + end - option :node_verify_api_cert, - long: "--[no-]node-verify-api-cert", - description: "Verify the SSL cert for HTTPS requests to the Chef server API.", - boolean: true - - option :bootstrap_vault_file, - long: "--bootstrap-vault-file VAULT_FILE", - description: "A JSON file with a list of vault(s) and item(s) to be updated" - - option :bootstrap_vault_json, - long: "--bootstrap-vault-json VAULT_JSON", - description: "A JSON string with the vault(s) and item(s) to be updated" - - option :bootstrap_vault_item, - long: "--bootstrap-vault-item VAULT_ITEM", - description: 'A single vault and item to update as "vault:item"', - proc: Proc.new { |i| - (vault, item) = i.split(/:/) - Chef::Config[:knife][:bootstrap_vault_item] ||= {} - Chef::Config[:knife][:bootstrap_vault_item][vault] ||= [] - Chef::Config[:knife][:bootstrap_vault_item][vault].push(item) - Chef::Config[:knife][:bootstrap_vault_item] - } + banner "knife bootstrap [PROTOCOL://][USER@]FQDN (options)" def initialize(argv = []) super @@ -259,12 +61,16 @@ class Chef ) end - # The default bootstrap template to use to bootstrap a server This is a public API hook - # which knife plugins use or inherit and override. + # The default bootstrap template to use to bootstrap a server. + # This is a public API hook which knife plugins use or inherit and override. # # @return [String] Default bootstrap template def default_bootstrap_template - "chef-full" + if target_host.base_os == :windows + "windows-chef-client-msi" + else + "chef-full" + end end def host_descriptor @@ -282,15 +88,10 @@ class Chef end end - def user_name - if host_descriptor - @user_name ||= host_descriptor.split("@").reverse[1] - end - end + # @return [String] The CLI specific bootstrap template or the default def bootstrap_template # Allow passing a bootstrap template or use the default - # @return [String] The CLI specific bootstrap template or the default config[:bootstrap_template] || default_bootstrap_template end @@ -330,13 +131,18 @@ class Chef @secret ||= encryption_secret_provided_ignore_encrypt_flag? ? read_secret : nil end + # Establish bootstrap context for template rendering. + # Requires target_host to be a live connection in order to determine + # the correct platform. def bootstrap_context - @bootstrap_context ||= Knife::Core::BootstrapContext.new( - config, - config[:run_list], - Chef::Config, - secret - ) + @bootstrap_context ||= + if target_host.base_os == :windows + require "chef/knife/core/windows_bootstrap_context" + Knife::Core::WindowsBootstrapContext.new(config, config[:run_list], Chef::Config, secret) + else + require "chef/knife/core/bootstrap_context" + Knife::Core::BootstrapContext.new(config, config[:run_list], Chef::Config, secret) + end end def first_boot_attributes @@ -351,48 +157,153 @@ class Chef end def run - if @config[:first_boot_attributes] && @config[:first_boot_attributes_from_file] - raise Chef::Exceptions::BootstrapCommandInputError - end - validate_name_args! - validate_options! + validate_protocol! + validate_first_boot_attributes! + validate_winrm_transport_opts! + validate_policy_options! + + winrm_warn_no_ssl_verification $stdout.sync = true + register_client + connect! + unless client_builder.client_path.nil? + bootstrap_context.client_pem = client_builder.client_path + end + content = render_template + bootstrap_path = upload_bootstrap(content) + perform_bootstrap(bootstrap_path) + ensure + target_host.del_file(bootstrap_path) if target_host && 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]))) + (Chef::Config[:validation_key] && + !File.exist?(File.expand_path(Chef::Config[:validation_key]))) unless config[:chef_node_name] ui.error("You must pass a node name with -N when bootstrapping with user credentials") exit 1 end - client_builder.run - chef_vault_handler.run(client_builder.client) - - bootstrap_context.client_pem = client_builder.client_path else - ui.info("Doing old-style registration with the validation key at #{Chef::Config[:validation_key]}...") - ui.info("Delete your validation key in order to use your user credentials instead") - ui.info("") + ui.info <<~EOM + Doing old-style registration with the validation key at #{Chef::Config[:validation_key]}..." + Delete your validation key in order to use your user credentials instead + EOM + end + end - ui.info("Connecting to #{ui.color(server_name, :bold)}") - begin - knife_ssh.run - rescue Net::SSH::AuthenticationFailed - if config[:ssh_password] + 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}") + end + if r.exit_status != 0 + ui.error("The following error occurred on #{server_name}:") + ui.error(r.stderr) + exit 1 + end + end + + def connect! + + 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] raise else - ui.info("Failed to authenticate #{knife_ssh.config[:ssh_user]} - trying password auth") - knife_ssh_with_password_auth.run + 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| + q.echo = false + end + end + opts.merge! force_ssh_password_opts(password) + do_connect(opts) + else + raise + end + end + + def connection_protocol + return @connection_protocol if @connection_protocol + from_url = host_descriptor =~ /^(.*):\/\// ? $1 : nil + from_cli = config[:connection_protocol] + from_knife = Chef::Config[:knife][:connection_protocol] + @connection_protocol = from_url || from_cli || from_knife || "ssh" + 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 + end + + # Fail if both first_boot_attributes and first_boot_attributes_from_file + # are set. + def validate_first_boot_attributes! + if @config[:first_boot_attributes] && @config[:first_boot_attributes_from_file] + raise Chef::Exceptions::BootstrapCommandInputError + end + true + end + + # Fail if using plaintext auth without ssl because + # this can expose keys in plaintext on the wire. + # TODO test for this method + # TODO check that the protoocol is valid. + def validate_winrm_transport_opts! + return true if connection_protocol != "winrm" + + + if (Chef::Config[:validation_key] && !File.exist?(File.expand_path(Chef::Config[:validation_key]))) + if (config_value(:winrm_auth_method) == "plaintext" && + config_value(:winrm_ssl) != true) + ui.error <<~EOM + Validatorless bootstrap over unsecure winrm channels could expose your + key to network sniffing. + + Please use a 'winrm_auth_method' other than 'plaintext', + or enable ssl on #{server_name} then use the --ssl flag + to connect. + EOM + + exit 1 end end + true end # fail if the server_name is nil @@ -400,9 +311,6 @@ class Chef if server_name.nil? ui.error("Must pass an FQDN or ip to bootstrap") exit 1 - elsif server_name == "windows" - # catches "knife bootstrap windows" when that command is not installed - ui.warn("'knife bootstrap windows' specified, but the knife-windows plugin is not installed. Please install 'knife-windows' if you are attempting to bootstrap a Windows node via WinRM.") end end @@ -413,7 +321,7 @@ class Chef # * Policyfile options are set and --run-list is set as well # # @return [TrueClass] If options are valid. - def validate_options! + def validate_policy_options! if incomplete_policyfile_options? ui.error("--policy-name and --policy-group must be specified together") exit 1 @@ -421,51 +329,299 @@ class Chef ui.error("Policyfile options and --run-list are exclusive") exit 1 end - true end - # setup a Chef::Knife::Ssh object using the passed config options + # Ensure a valid protocol is provided for target host connection # - # @return Chef::Knife::Ssh - def knife_ssh - ssh = Chef::Knife::Ssh.new - ssh.ui = ui - ssh.name_args = [ server_name, ssh_command ] - ssh.config[:ssh_user] = user_name || config[:ssh_user] - ssh.config[:ssh_password] = config[:ssh_password] - ssh.config[:ssh_port] = config[:ssh_port] - ssh.config[:ssh_gateway] = config[:ssh_gateway] - ssh.config[:ssh_gateway_identity] = config[:ssh_gateway_identity] - ssh.config[:forward_agent] = config[:forward_agent] - ssh.config[:ssh_identity_file] = config[:ssh_identity_file] - ssh.config[:manual] = true - ssh.config[:host_key_verify] = config[:host_key_verify] - ssh.config[:on_error] = true - ssh - end - - # prompt for a password then return a knife ssh object with that password set - # and with ssh_identity_file set to nil + # The method call will cause the program to exit(1) if: + # * Conflicting protocols are given via the target URI and the --protocol option + # * The protocol is not a supported protocol + # + # @return [TrueClass] If options are valid. + def validate_protocol! + from_cli = config[:connection_protocol] + if (from_cli && connection_protocol != from_cli) + # Hanging indent to align with the ERROR: prefix + ui.error <<~EOM + The URL '#{host_descriptor}' indicates protocol is '#{connection_protocol}' + while the --protocol flag specifies '#{from_cli}'. Please include + only one or the other. + EOM + exit 1 + end + + unless SUPPORTED_CONNECTION_PROTOCOLS.include?(connection_protocol) + ui.error <<~EOM + Unsupported protocol '#{connection_protocol}'. + + Supported protocols are: #{SUPPORTED_CONNECTION_PROTOCOLS.join(" ")} + EOM + exit 1 + end + true + end + + def winrm_warn_no_ssl_verification + return if connection_protocol != "winrm" + + # REVIEWER NOTE + # The original check from knife plugin did not include winrm_ssl_peer_fingerprint + # Reference: + # https://github.com/chef/knife-windows/blob/92d151298142be4a4750c5b54bb264f8d5b81b8a/lib/chef/knife/winrm_knife_base.rb#L271-L273 + # TODO Seems like we should also do a similar warning if ssh_verify_host == false + if config_value(:ca_trust_file).nil? && + config_value(:winrm_no_verify_cert) && + config_value(:winrm_ssl_peer_fingerprint).nil? + ui.warn <<~WARN + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + SSL validation of HTTPS requests for the WinRM transport is disabled. + HTTPS WinRM # connections are still encrypted, but knife is not able + to detect forged replies # or spoofing attacks. + + To fix this issue add an entry like this to your knife configuration file: + + # Verify all WinRM HTTPS connections (default, recommended) + knife[:winrm_no_verify_cert] = false + + You can also specify a ca_trust_file via --ca-trust-file, + or the expected fingerprint of the target host via + --winrm-ssl-peer-fingerprint. + WARN + end + end + + # + + # Create a configuration hash for TargetHost to connect + # to the remote host via Train. # - # @return Chef::Knife::Ssh - def knife_ssh_with_password_auth - ssh = knife_ssh - ssh.config[:ssh_identity_file] = nil - ssh.config[:ssh_password] = ssh.get_password - ssh + # @return a configuration hash suitable for connecting to the remote + # host via TargetHost. + def connection_opts + return @connection_opts unless @connection_opts.nil? + @connection_opts = {} + @connection_opts.merge! base_opts + @connection_opts.merge! host_verify_opts + @connection_opts.merge! gateway_opts + @connection_opts.merge! sudo_opts + @connection_opts.merge! winrm_opts + @connection_opts.merge! ssh_opts + @connection_opts.merge! ssh_identity_opts + @connection_opts end - # build the ssh dommand for bootrapping - # @return String - def ssh_command - command = render_template + # 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, + knife_key_for_protocol(connection_protocol, :user)) + {}.tap do |opts| + opts[:logger] = Chef::Log + # We do not store password in Chef::Config, so only use CLI `config` here + opts[:password] = config[:password] if config.key?(:password) + opts[:user] = user if user + opts[:max_wait_until_ready] = config_value(:max_wait) unless config_value(:max_wait).nil? + # TODO - when would we need to provide rdp_port vs port? Or are they not mutually exclusive? + opts[:port] = port if port + end + end + + def host_verify_opts + case connection_protocol + when "winrm" + { self_signed: config_value(:winrm_no_verify_cert) === true } + when "ssh" + # TODO is this a safe footgun to provide? Seems a security risk + # if someone forgets to If someone forgets ssh_verify_host_key is + # is in knife config, s- setting ssh_verify_host_key to true + # in knife.rb, and forgetting it's there? + { verify_host_key: config_value(:ssh_verify_host_key, + :host_key_verify, true) === true } + else + {} + end + end + + def ssh_opts + opts = {} + return opts if connection_protocol == "winrm" + opts[:forward_agent] = (config_value(:ssh_forward_agent) === true) + opts + end + def ssh_identity_opts + opts = {} + return opts if connection_protocol == "winrm" + identity_file = config_value(:ssh_identity_file) + if identity_file + opts[:key_files] = [identity_file] + # We only set keys_only based on the explicit ssh_identity_file; + # someone may use a gateway key and still expect password auth + # on the target. Similarly, someone may have a default key specified + # in knife config, but have provided a password on the CLI. + + # REVIEW NOTE: this is a new behavior. Originally, ssh_identity_file + # could only be populated from CLI options, so there was no need to check + # for this. We will also set keys_only to false only if there are keys + # and no password. + # If both are present, train(via net/ssh) will prefer keys, falling back to password. + # Reference: https://github.com/chef/chef/blob/master/lib/chef/knife/ssh.rb#L272 + opts[:keys_only] = config.key?(:password) == false + else + opts[:key_files] = [] + opts[:keys_only] = false + end + + gateway_identity_file = config_value(:ssh_gateway) ? config_value(:ssh_gateway_identity) : nil + unless gateway_identity_file.nil? + opts[:key_files] << gateway_identity_file + end + + opts + end + + def gateway_opts + opts = {} + if config_value(:ssh_gateway) + split = config_value(:ssh_gateway).split("@", 2) + if split.length == 1 + gw_host = split[0] + else + gw_user = split[0] + gw_host = split[1] + end + gw_host, gw_port = gw_host.split(":", 2) + # TODO - validate convertable port in config validation? + gw_port = Integer(gw_port) rescue nil + opts[:bastion_host] = gw_host + opts[:bastion_user] = gw_user + opts[:bastion_port] = gw_port + end + opts + end + + + # use_sudo - tells bootstrap to use the sudo command to run bootstrap + # use_sudo_password - tells bootstrap to use the sudo command to run bootstrap + # and to use the password specified with --password + # TODO: I'd like to make our sudo options sane: + # --sudo (bool) - use sudo + # --sudo-password PASSWORD (default: :password) - use this password for sudo + # --sudo-options "opt,opt,opt" to pass into sudo + # --sudo-command COMMAND sudo command other than sudo + # REVIEW NOTE: knife bootstrap did not pull sudo values from Chef::Config, + # should we change that for consistency? + def sudo_opts + return {} if connection_protocol == "winrm" + opts = { sudo: false } if config[:use_sudo] - sudo_prefix = config[:use_sudo_password] ? "echo '#{config[:ssh_password]}' | sudo -S " : "sudo " - command = config[:preserve_home] ? "#{sudo_prefix} #{command}" : "#{sudo_prefix} -H #{command}" + opts[:sudo] = true + if config[:use_sudo_password] + opts[:sudo_password] = config[:password] + end + if config[:preserve_home] + opts[:sudo_options] = "-H" + end + end + opts + end + + def winrm_opts + return {} unless connection_protocol == "winrm" + auth_method = config_value(:winrm_auth_method, :winrm_auth_method, "negotiate") + opts = { + winrm_transport: auth_method, # winrm gem and train calls auth method 'transport' + winrm_basic_auth_only: config_value(:winrm_basic_auth_only) || false, + ssl: config_value(:winrm_ssl) === true, + ssl_peer_fingerprint: config_value(:winrm_ssl_peer_fingerprint) + } + + if auth_method == "kerberos" + opts[:kerberos_service] = config_value(:kerberos_service) if config_value(:kerberos_service) + opts[:kerberos_realm] = config_value(:kerberos_realm) if config_value(:kerberos_service) + end + + if config_value(:ca_trust_file) + opts[:ca_trust_file] = config_value(:ca_trust_file) + end + + opts[:operation_timeout] = config_value(:winrm_session_timeout) || 60 + + opts + end + + + # Config overrides to force password auth. + def force_ssh_password_opts(password) + { + password: password, + non_interactive: false, + keys_only: false, + key_files: [], + auth_methods: [:password, :keyboard_interactive] + } + end + + # Looks up configuration entries, first in the class member + # `config` which contains options populated from CLI flags. + # If the entry is not found there, Chef::Config[:knife][KEY] + # is checked. + # + # knife_config_key should be specified if the knife config lookup + # key is different from the CLI flag lookup key. + # + def config_value(key, knife_config_key = nil, default = nil) + if config.key? key + config[key] + else + lookup_key = knife_config_key || key + if Chef::Config[:knife].key?(lookup_key) + Chef::Config[:knife][lookup_key] + else + default + end end + end - command + # Tells us where a config value has come from , + # :cli_config, :knife_config, :not_found + def config_source(key, knife_config_key = nil) + return :cli_config if config.key? key + return :knife_config if config.key?(key) || config.key?(knife_config_key) + :not_found + 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) + remote_path + end + + + # build the command string for bootrapping + # @return String + def bootstrap_command(remote_path) + if target_host.base_os == :windows + "cmd.exe /C #{remote_path}" + else + "sh #{remote_path}" + end + end + + + # 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. + # + # 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. + def knife_key_for_protocol(protocol, option) + "#{connection_protocol}_#{option.to_s}".to_sym end private @@ -487,7 +643,6 @@ class Chef def incomplete_policyfile_options? (!!config[:policy_name] ^ config[:policy_group]) end - end end end diff --git a/lib/chef/knife/bootstrap/options.rb b/lib/chef/knife/bootstrap/options.rb new file mode 100644 index 0000000000..350aef0959 --- /dev/null +++ b/lib/chef/knife/bootstrap/options.rb @@ -0,0 +1,358 @@ +# Author:: Marc Paradise (<marc@chef.io>) +# 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. +# + +class Chef + class Knife + class Bootstrap + module Options + # REVIEWER: let's talk about which protocols we want to support. + # TODO this should be available from train, which we can make our source of truth. + # TODO - we don't actually validate that the protocol is valid... + WINRM_AUTH_PROTOCOL_LIST = %w{plaintext kerberos ssl negotiate} + + #TODO - missing - authtimeout (minutes) + #TODO - missing impl - session-timeout minutes + def self.included(includer) + includer.class_eval do + + # Common connectivity options + option :connection_user, + short: "-U USERNAME", + long: "--connection-user USERNAME", + description: "Authenticate to the target host with this user account" + + option :password, + short: "-P PASSWORD", + long: "--connection-password PASSWORD", + description: "Authenticate to the target host with this password" + + option :connection_port, + short: "-p PORT", + long: "--connection-port PORT", + description: "The port on the target node to connect to." + + option :connection_protocol, + short: "-o PROTOCOL", + long: "--connection-protocol PROTOCOL", + description: "The protocol to use to connect to the target node. Supports ssh and winrm." + + option :max_wait, + short: "-W SECONDS", + long: "--max-wait SECONDS", + description: "The maximum time to wait for the initial connection to be established." + + ## SSH options + + option :ssh_gateway, + short: "-G GATEWAY", + long: "--ssh-gateway GATEWAY", + description: "The ssh gateway", + proc: Proc.new { |key| Chef::Config[:knife][:ssh_gateway] = key } + + option :ssh_gateway_identity, + long: "--ssh-gateway-identity SSH_GATEWAY_IDENTITY", + description: "The SSH identity file used for gateway authentication", + proc: Proc.new { |key| Chef::Config[:knife][:ssh_gateway_identity] = key } + + # SSH train ssh: options[:forward_agent] + option :ssh_forward_agent, + short: "-A", + long: "--ssh-forward-agent", + description: "Enable SSH agent forwarding", + boolean: true + + # SSH train: options[key_files] + option :ssh_identity_file, + short: "-i IDENTITY_FILE", + long: "--ssh-identity-file IDENTITY_FILE", + description: "The SSH identity file used for authentication" + + # ssh options - train options[:verify_host_key] + option :ssh_verify_host_key, + long: "--ssh-[no-]verify-host-key", + description: "Verify host key, enabled by default.", + boolean: true + + # argument to installer in chef-full, via bootstrap_context + option :prerelease, + long: "--prerelease", + description: "Install the pre-release chef gems" + + # client.rb content via chef-full/bootstrap_context + option :bootstrap_version, + long: "--bootstrap-version VERSION", + description: "The version of Chef to install", + proc: lambda { |v| Chef::Config[:knife][:bootstrap_version] = v } + + # client.rb content via chef-full/bootstrap_context + option :bootstrap_proxy, + long: "--bootstrap-proxy PROXY_URL", + description: "The proxy server for the node being bootstrapped", + proc: Proc.new { |p| Chef::Config[:knife][:bootstrap_proxy] = p } + + # client.rb content via bootstrap_context + option :bootstrap_proxy_user, + long: "--bootstrap-proxy-user PROXY_USER", + description: "The proxy authentication username for the node being bootstrapped" + + # client.rb content via bootstrap_context + option :bootstrap_proxy_pass, + long: "--bootstrap-proxy-pass PROXY_PASS", + description: "The proxy authentication password for the node being bootstrapped" + + # client.rb content via bootstrap_context + option :bootstrap_no_proxy, + long: "--bootstrap-no-proxy [NO_PROXY_URL|NO_PROXY_IP]", + description: "Do not proxy locations for the node being bootstrapped; this option is used internally by Chef", + proc: Proc.new { |np| Chef::Config[:knife][:bootstrap_no_proxy] = np } + + # client.rb content via bootstrap_context + option :bootstrap_template, + short: "-t TEMPLATE", + long: "--bootstrap-template TEMPLATE", + description: "Bootstrap Chef using a built-in or custom template. Set to the full path of an erb template or use one of the built-in templates." + + # client.rb content via bootstrap_context + option :node_ssl_verify_mode, + long: "--node-ssl-verify-mode [peer|none]", + description: "Whether or not to verify the SSL cert for all HTTPS requests.", + proc: Proc.new { |v| + valid_values = %w{none peer} + unless valid_values.include?(v) + raise "Invalid value '#{v}' for --node-ssl-verify-mode. Valid values are: #{valid_values.join(", ")}" + end + v + } + + # bootstrap_context - client.rb + option :node_verify_api_cert, + long: "--[no-]node-verify-api-cert", + description: "Verify the SSL cert for HTTPS requests to the Chef server API.", + boolean: true + + # runtime - sudo settings (train handles sudo) + option :use_sudo, + long: "--sudo", + description: "Execute the bootstrap via sudo", + boolean: true + + # runtime - sudo settings (train handles sudo) + option :preserve_home, + long: "--sudo-preserve-home", + description: "Preserve non-root user HOME environment variable with sudo", + boolean: true + + # runtime - sudo settings (train handles sudo) + option :use_sudo_password, + long: "--use-sudo-password", + description: "Execute the bootstrap via sudo with password", + boolean: false + + # runtime - client_builder + option :chef_node_name, + short: "-N NAME", + long: "--node-name NAME", + description: "The Chef node name for your new node" + + # runtime - client_builder - set runlist when creating node + option :run_list, + short: "-r RUN_LIST", + long: "--run-list RUN_LIST", + description: "Comma separated list of roles/recipes to apply", + proc: lambda { |o| o.split(/[\s,]+/) }, + default: [] + + # runtime - client_builder - set policy name when creating node + option :policy_name, + long: "--policy-name POLICY_NAME", + description: "Policyfile name to use (--policy-group must also be given)", + default: nil + + # runtime - client_builder - set policy group when creating node + option :policy_group, + long: "--policy-group POLICY_GROUP", + description: "Policy group name to use (--policy-name must also be given)", + default: nil + + # runtime - client_builder - node tags + option :tags, + long: "--tags TAGS", + description: "Comma separated list of tags to apply to the node", + proc: lambda { |o| o.split(/[\s,]+/) }, + default: [] + + # bootstrap template + option :first_boot_attributes, + short: "-j JSON_ATTRIBS", + long: "--json-attributes", + description: "A JSON string to be added to the first run of chef-client", + proc: lambda { |o| Chef::JSONCompat.parse(o) }, + default: nil + + # bootstrap template + option :first_boot_attributes_from_file, + long: "--json-attribute-file FILE", + description: "A JSON file to be used to the first run of chef-client", + proc: lambda { |o| Chef::JSONCompat.parse(File.read(o)) }, + default: nil + + # Note that several of the below options are used by bootstrap template, + # but only from the passed-in knife config; it does not use the + # config from the CLI for those values. In those cases, the option + # will have a proc that assigns the value into Chef::Config[:knife] + + # bootstrap template + # Create ohai hints in /etc/chef/ohai/hints, fname=hintname, content=value + option :hint, + long: "--hint HINT_NAME[=HINT_FILE]", + description: "Specify Ohai Hint to be set on the bootstrap target. Use multiple --hint options to specify multiple hints.", + proc: Proc.new { |h| + Chef::Config[:knife][:hints] ||= Hash.new + name, path = h.split("=") + Chef::Config[:knife][:hints][name] = path ? Chef::JSONCompat.parse(::File.read(path)) : Hash.new + } + + # bootstrap override: url of a an installer shell script touse in place of omnitruck + # Note that the bootstrap template _only_ references this out of Chef::Config, and not from + # the provided options to knife bootstrap, so we set the Chef::Config option here. + option :bootstrap_url, + long: "--bootstrap-url URL", + description: "URL to a custom installation script", + proc: Proc.new { |u| Chef::Config[:knife][:bootstrap_url] = u } + + + # bootstrap override: Do this instead of our own setup.sh from omnitruck. Causes bootstrap_url to be ignored. + option :bootstrap_install_command, + long: "--bootstrap-install-command COMMANDS", + description: "Custom command to install chef-client", + proc: Proc.new { |ic| Chef::Config[:knife][:bootstrap_install_command] = ic } + + # bootstrap template: Run this command first in the bootstrap script + option :bootstrap_preinstall_command, + long: "--bootstrap-preinstall-command COMMANDS", + description: "Custom commands to run before installing chef-client", + proc: Proc.new { |preic| Chef::Config[:knife][:bootstrap_preinstall_command] = preic } + + # bootstrap template + option :bootstrap_wget_options, + long: "--bootstrap-wget-options OPTIONS", + description: "Add options to wget when installing chef-client", + proc: Proc.new { |wo| Chef::Config[:knife][:bootstrap_wget_options] = wo } + + # bootstrap template + option :bootstrap_curl_options, + long: "--bootstrap-curl-options OPTIONS", + description: "Add options to curl when install chef-client", + proc: Proc.new { |co| Chef::Config[:knife][:bootstrap_curl_options] = co } + + # chef_vault_handler + option :bootstrap_vault_file, + long: "--bootstrap-vault-file VAULT_FILE", + description: "A JSON file with a list of vault(s) and item(s) to be updated" + + # chef_vault_handler + option :bootstrap_vault_json, + long: "--bootstrap-vault-json VAULT_JSON", + description: "A JSON string with the vault(s) and item(s) to be updated" + + # chef_vault_handler + option :bootstrap_vault_item, + long: "--bootstrap-vault-item VAULT_ITEM", + description: 'A single vault and item to update as "vault:item"', + proc: Proc.new { |i| + (vault, item) = i.split(/:/) + Chef::Config[:knife][:bootstrap_vault_item] ||= {} + Chef::Config[:knife][:bootstrap_vault_item][vault] ||= [] + Chef::Config[:knife][:bootstrap_vault_item][vault].push(item) + Chef::Config[:knife][:bootstrap_vault_item] + } + + # Windows only + + # bootstrap template + option :install_as_service, + :long => "--install-as-service", + :description => "Install chef-client as a Windows service. (Windows only)", + :default => false + + # bootstrap template + option :msi_url, + :short => "-m URL", + :long => "--msi-url URL", + :description => "Location of the Chef Client MSI. The default templates will prefer to download from this location. The MSI will be downloaded from chef.io if not provided (windows).", + :default => '' + + option :winrm_ssl_peer_fingerprint, + :long => "--winrm-ssl-peer-fingerprint FINGERPRINT", + :description => "SSL certificate fingerprint expected from the target." + + option :ca_trust_file, + :short => "-f CA_TRUST_PATH", + :long => "--ca-trust-file CA_TRUST_PATH", + :description => "The Certificate Authority (CA) trust file used for SSL transport" + + option :winrm_no_verify_cert, + long: "--winrm-no-verify-cert", + description: "Do not verify the SSL certificate of the target node for WinRM." + + + option :winrm_ssl, + long: "--winrm-ssl", + description: "Connect to WinRM using SSL" + + option :winrm_auth_method, + :short => "-w AUTH-METHOD", + :long => "--winrm-auth-method AUTH-METHOD", + :description => "The WinRM authentication method to use. Valid choices are #{WINRM_AUTH_PROTOCOL_LIST}", + :proc => Proc.new { |protocol| Chef::Config[:knife][:winrm_auth_method] = protocol } + + option :winrm_basic_auth_only, + long: "--winrm-basic-auth-only", + description: "For WinRM basic authentication when using the 'ssl' auth method", + boolean: true + + # This option was provided in knife bootstrap windows winrm, + # but it is ignored in knife-windows/WinrmSession, and so remains unimplemeneted here. + # option :kerberos_keytab_file, + # :short => "-T KEYTAB_FILE", + # :long => "--keytab-file KEYTAB_FILE", + # :description => "The Kerberos keytab file used for authentication", + # :proc => Proc.new { |keytab| Chef::Config[:knife][:kerberos_keytab_file] = keytab } + + option :kerberos_realm, + :short => "-R KERBEROS_REALM", + :long => "--kerberos-realm KERBEROS_REALM", + :description => "The Kerberos realm used for authentication", + :proc => Proc.new { |protocol| Chef::Config[:knife][:kerberos_realm] = protocol } + + option :kerberos_service, + :short => "-S KERBEROS_SERVICE", + :long => "--kerberos-service KERBEROS_SERVICE", + :description => "The Kerberos service used for authentication", + :proc => Proc.new { |protocol| Chef::Config[:knife][:kerberos_service] = protocol } + + option :winrm_session_timeout, + :long => "--winrm-session-timeout SECONDS", + :description => "The number of seconds to wait for each WinRM operation to be acknowledged while running bootstrap", + :proc => Proc.new { |protocol| Chef::Config[:knife][:winrm_session_timeout] = protocol } + + end + end + end + end + end +end diff --git a/lib/chef/knife/bootstrap/templates/windows-chef-client-msi.erb b/lib/chef/knife/bootstrap/templates/windows-chef-client-msi.erb new file mode 100644 index 0000000000..c30a22bd94 --- /dev/null +++ b/lib/chef/knife/bootstrap/templates/windows-chef-client-msi.erb @@ -0,0 +1,271 @@ +@rem +@rem Author:: Seth Chisamore (<schisamo@chef.io>) +@rem Copyright:: Copyright (c) 2011-2017 Chef Software, Inc. +@rem License:: Apache License, Version 2.0 +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem http://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@rem Use delayed environment expansion so that ERRORLEVEL can be evaluated with the +@rem !ERRORLEVEL! syntax which evaluates at execution of the line of script, not when +@rem the line is read. See help for the /E switch from cmd.exe /? . +@setlocal ENABLEDELAYEDEXPANSION + +<%= "SETX HTTP_PROXY \"#{knife_config[:bootstrap_proxy]}\"" if knife_config[:bootstrap_proxy] %> + +@set BOOTSTRAP_DIRECTORY=<%= bootstrap_directory %> +@echo Checking for existing directory "%BOOTSTRAP_DIRECTORY%"... +@if NOT EXIST %BOOTSTRAP_DIRECTORY% ( + @echo Existing directory not found, creating. + @mkdir %BOOTSTRAP_DIRECTORY% +) else ( + @echo Existing directory found, skipping creation. +) + +> <%= bootstrap_directory %>\wget.vbs ( + <%= win_wget %> +) + +> <%= bootstrap_directory %>\wget.ps1 ( + <%= win_wget_ps %> +) + +@rem Determine the version and the architecture + +@FOR /F "usebackq tokens=1-8 delims=.[] " %%A IN (`ver`) DO ( +@set WinMajor=%%D +@set WinMinor=%%E +@set WinBuild=%%F +) + +@echo Detected Windows Version %WinMajor%.%WinMinor% Build %WinBuild% + +@set LATEST_OS_VERSION_MAJOR=10 +@set LATEST_OS_VERSION_MINOR=1 + +@if /i %WinMajor% GTR %LATEST_OS_VERSION_MAJOR% goto VersionUnknown +@if /i %WinMajor% EQU %LATEST_OS_VERSION_MAJOR% ( + @if /i %WinMinor% GTR %LATEST_OS_VERSION_MINOR% goto VersionUnknown +) + +goto Version%WinMajor%.%WinMinor% + +:VersionUnknown +@rem If this is an unknown version of windows set the default +@set MACHINE_OS=2012r2 +@echo Warning: Unknown version of Windows, assuming default of Windows %MACHINE_OS% +goto architecture_select + +:Version6.0 +@set MACHINE_OS=2008 +goto architecture_select + +:Version5.2 +@set MACHINE_OS=2003r2 +goto architecture_select + +:Version6.1 +@set MACHINE_OS=2008r2 +goto architecture_select + +:Version6.2 +@set MACHINE_OS=2012 +goto architecture_select + +@rem Currently Windows Server 2012 R2 is treated as equivalent to Windows Server 2012 +:Version6.3 +@set MACHINE_OS=2012r2 +goto architecture_select + +:Version10.0 +@set MACHINE_OS=2016 +goto architecture_select + +@rem Currently Windows Server 2016 R2 is treated as equivalent to Windows Server 2016 +:Version10.1 +goto Version10.0 + +:architecture_select +<% if knife_config[:architecture] %> + @set MACHINE_ARCH=<%= knife_config[:architecture] %> + + <% if knife_config[:architecture] == "x86_64" %> + IF "%PROCESSOR_ARCHITECTURE%"=="x86" IF not defined PROCESSOR_ARCHITEW6432 ( + echo You specified bootstrap_architecture as x86_64 but the target machine is i386. A 64 bit program cannot run on a 32 bit machine. > "&2" + echo Exiting without bootstrapping. > "&2" + exit /b 1 + ) + <% end %> +<% else %> + @set MACHINE_ARCH=x86_64 + IF "%PROCESSOR_ARCHITECTURE%"=="x86" IF not defined PROCESSOR_ARCHITEW6432 @set MACHINE_ARCH=i686 +<% end %> +goto chef_installed + +:chef_installed +@echo Checking for existing chef installation +WHERE chef-client >nul 2>nul +If !ERRORLEVEL!==0 ( + @echo Existing Chef installation detected, skipping download + goto key_create +) else ( + @echo No existing installation of chef detected + goto install +) + +:install +@rem If user has provided the custom installation command for chef-client then execute it +<% if @chef_config[:knife][:bootstrap_install_command] %> + <%= @chef_config[:knife][:bootstrap_install_command] %> +<% else %> + @rem Install Chef using chef-client MSI installer + + @set "LOCAL_DESTINATION_MSI_PATH=<%= local_download_path %>" + @set "CHEF_CLIENT_MSI_LOG_PATH=%TEMP%\chef-client-msi%RANDOM%.log" + + @rem Clear any pre-existing downloads + @echo Checking for existing downloaded package at "%LOCAL_DESTINATION_MSI_PATH%" + @if EXIST "%LOCAL_DESTINATION_MSI_PATH%" ( + @echo Found existing downloaded package, deleting. + @del /f /q "%LOCAL_DESTINATION_MSI_PATH%" + @if ERRORLEVEL 1 ( + echo Warning: Failed to delete pre-existing package with status code !ERRORLEVEL! > "&2" + ) + ) else ( + echo No existing downloaded packages to delete. + ) + + @rem If there is somehow a name collision, remove pre-existing log + @if EXIST "%CHEF_CLIENT_MSI_LOG_PATH%" del /f /q "%CHEF_CLIENT_MSI_LOG_PATH%" + + @echo Attempting to download client package using PowerShell if available... + @set "REMOTE_SOURCE_MSI_URL=<%= msi_url('%MACHINE_OS%', '%MACHINE_ARCH%', 'PowerShell') %>" + @set powershell_download=powershell.exe -ExecutionPolicy Unrestricted -InputFormat None -NoProfile -NonInteractive -File <%= bootstrap_directory %>\wget.ps1 "%REMOTE_SOURCE_MSI_URL%" "%LOCAL_DESTINATION_MSI_PATH%" + @echo !powershell_download! + @call !powershell_download! + + @set DOWNLOAD_ERROR_STATUS=!ERRORLEVEL! + + @if ERRORLEVEL 1 ( + @echo Failed PowerShell download with status code !DOWNLOAD_ERROR_STATUS! > "&2" + @if !DOWNLOAD_ERROR_STATUS!==0 set DOWNLOAD_ERROR_STATUS=2 + ) else ( + @rem Sometimes the error level is not set even when the download failed, + @rem so check for the file to be sure it is there -- if it is not, we will retry + @if NOT EXIST "%LOCAL_DESTINATION_MSI_PATH%" ( + echo Failed download: download completed, but downloaded file not found > "&2" + set DOWNLOAD_ERROR_STATUS=2 + ) else ( + echo Download via PowerShell succeeded. + ) + ) + + @if NOT %DOWNLOAD_ERROR_STATUS%==0 ( + @echo Warning: Failed to download "%REMOTE_SOURCE_MSI_URL%" to "%LOCAL_DESTINATION_MSI_PATH%" + @echo Warning: Retrying download with cscript ... + + @if EXIST "%LOCAL_DESTINATION_MSI_PATH%" del /f /q "%LOCAL_DESTINATION_MSI_PATH%" + + @set "REMOTE_SOURCE_MSI_URL=<%= msi_url('%MACHINE_OS%', '%MACHINE_ARCH%') %>" + cscript /nologo <%= bootstrap_directory %>\wget.vbs /url:"%REMOTE_SOURCE_MSI_URL%" /path:"%LOCAL_DESTINATION_MSI_PATH%" + + @if NOT ERRORLEVEL 1 ( + @rem Sometimes the error level is not set even when the download failed, + @rem so check for the file to be sure it is there. + @if NOT EXIST "%LOCAL_DESTINATION_MSI_PATH%" ( + echo Failed download: download completed, but downloaded file not found > "&2" + echo Exiting without bootstrapping due to download failure. > "&2" + exit /b 1 + ) else ( + echo Download via cscript succeeded. + ) + ) else ( + echo Failed to download "%REMOTE_SOURCE_MSI_URL%" with status code !ERRORLEVEL!. > "&2" + echo Exiting without bootstrapping due to download failure. > "&2" + exit /b 1 + ) + ) + + @echo Installing downloaded client package... + + <%= install_chef %> + + @if ERRORLEVEL 1 ( + echo Chef-client package failed to install with status code !ERRORLEVEL!. > "&2" + echo See installation log for additional detail: %CHEF_CLIENT_MSI_LOG_PATH%. > "&2" + ) else ( + @echo Installation completed successfully + del /f /q "%CHEF_CLIENT_MSI_LOG_PATH%" + ) + +<% end %> + +:key_create +@endlocal + +@echo off + +<% if client_pem -%> +> <%= bootstrap_directory %>\client.pem ( + <%= escape_and_echo(::File.read(::File.expand_path(client_pem))) %> +) +<% end -%> + +echo Writing validation key... + +<% if validation_key -%> +> <%= bootstrap_directory %>\validation.pem ( + <%= escape_and_echo(validation_key) %> +) +<% end -%> + +echo Validation key written. +@echo on + +<% if @config[:secret] -%> +> <%= bootstrap_directory %>\encrypted_data_bag_secret ( + <%= secret %> +) +<% end -%> + +<% unless trusted_certs_script.empty? -%> +mkdir <%= bootstrap_directory %>\trusted_certs +<%= trusted_certs_script %> +<% end -%> + +<%# Generate Ohai Hints -%> +<% unless @chef_config[:knife][:hints].nil? || @chef_config[:knife][:hints].empty? -%> +mkdir <%= bootstrap_directory %>\ohai\hints + +<% @chef_config[:knife][:hints].each do |name, hash| -%> +> <%= bootstrap_directory %>\ohai\hints\<%= name %>.json ( + <%= escape_and_echo(hash.to_json) %> +) +<% end -%> +<% end -%> + +> <%= bootstrap_directory %>\client.rb ( + <%= config_content %> +) + +> <%= bootstrap_directory %>\first-boot.json ( + <%= first_boot %> +) + +<% unless client_d.empty? -%> + mkdir <%= bootstrap_directory %>\client.d + <%= client_d %> +<% end -%> + +@echo Starting chef to bootstrap the node... +<%= start_chef %> diff --git a/lib/chef/knife/core/bootstrap_context.rb b/lib/chef/knife/core/bootstrap_context.rb index bd094e5021..287fe0a50f 100644 --- a/lib/chef/knife/core/bootstrap_context.rb +++ b/lib/chef/knife/core/bootstrap_context.rb @@ -161,7 +161,7 @@ class Chef end if Chef::Config[:fips] - client_rb << <<-CONFIG.gsub(/^ {14}/, "") + client_rb << <<~CONFIG fips true require "chef/version" chef_version = ::Chef::VERSION.split(".") diff --git a/lib/chef/knife/core/ui.rb b/lib/chef/knife/core/ui.rb index 3f0a697107..c696378912 100644 --- a/lib/chef/knife/core/ui.rb +++ b/lib/chef/knife/core/ui.rb @@ -85,6 +85,13 @@ class Chef alias :info :log alias :err :log + # Print a Debug + # + # @param message [String] the text string + def debug(message) + log("#{color('DEBUG:', :blue, :bold)} #{message}") + end + # Print a warning message # # @param message [String] the text string diff --git a/lib/chef/knife/core/windows_bootstrap_context.rb b/lib/chef/knife/core/windows_bootstrap_context.rb new file mode 100644 index 0000000000..6db017ca2f --- /dev/null +++ b/lib/chef/knife/core/windows_bootstrap_context.rb @@ -0,0 +1,412 @@ +# +# Author:: Seth Chisamore (<schisamo@chef.io>) +# Copyright:: Copyright (c) 2011-2016 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/knife/core/bootstrap_context' +require 'chef/util/path_helper' + +class Chef + class Knife + module Core + # Instances of BootstrapContext are the context objects (i.e., +self+) for + # bootstrap templates. For backwards compatability, they +must+ set the + # following instance variables: + # * @config - a hash of knife's config values + # * @run_list - the run list for the node to boostrap + # + class WindowsBootstrapContext < BootstrapContext + + def initialize(config, run_list, chef_config, secret=nil) + @config = config + @run_list = run_list + @chef_config = chef_config + @secret = secret + # Compatibility with Chef 12 and Chef 11 versions + begin + # Pass along the secret parameter for Chef 12 + super(config, run_list, chef_config, secret) + rescue ArgumentError + # The Chef 11 base class only has parameters for initialize + super(config, run_list, chef_config) + end + end + + def validation_key + if File.exist?(File.expand_path(@chef_config[:validation_key])) + IO.read(File.expand_path(@chef_config[:validation_key])) + else + false + end + end + + def secret + escape_and_echo(@config[:secret]) + end + + def trusted_certs_script + @trusted_certs_script ||= trusted_certs_content + end + + def config_content + client_rb = <<~CONFIG + chef_server_url "#{@chef_config[:chef_server_url]}" + validation_client_name "#{@chef_config[:validation_client_name]}" + file_cache_path "c:/chef/cache" + file_backup_path "c:/chef/backup" + cache_options ({:path => "c:/chef/cache/checksums", :skip_expires => true}) + CONFIG + if @config[:chef_node_name] + client_rb << %Q{node_name "#{@config[:chef_node_name]}"\n} + else + client_rb << "# Using default node name (fqdn)\n" + end + + if @chef_config[:config_log_level] + client_rb << %Q{log_level :#{@chef_config[:config_log_level]}\n} + else + client_rb << "log_level :auto\n" + end + + client_rb << "log_location #{get_log_location}" + + # We configure :verify_api_cert only when it's overridden on the CLI + # or when specified in the knife config. + if !@config[:node_verify_api_cert].nil? || knife_config.has_key?(:verify_api_cert) + value = @config[:node_verify_api_cert].nil? ? knife_config[:verify_api_cert] : @config[:node_verify_api_cert] + client_rb << %Q{verify_api_cert #{value}\n} + end + + # We configure :ssl_verify_mode only when it's overridden on the CLI + # or when specified in the knife config. + if @config[:node_ssl_verify_mode] || knife_config.has_key?(:ssl_verify_mode) + value = case @config[:node_ssl_verify_mode] + when "peer" + :verify_peer + when "none" + :verify_none + when nil + knife_config[:ssl_verify_mode] + else + nil + end + + if value + client_rb << %Q{ssl_verify_mode :#{value}\n} + end + end + + if @config[:ssl_verify_mode] + client_rb << %Q{ssl_verify_mode :#{knife_config[:ssl_verify_mode]}\n} + end + + if knife_config[:bootstrap_proxy] + client_rb << "\n" + client_rb << %Q{http_proxy "#{knife_config[:bootstrap_proxy]}"\n} + client_rb << %Q{https_proxy "#{knife_config[:bootstrap_proxy]}"\n} + client_rb << %Q{no_proxy "#{knife_config[:bootstrap_no_proxy]}"\n} if knife_config[:bootstrap_no_proxy] + end + + if knife_config[:bootstrap_no_proxy] + client_rb << %Q{no_proxy "#{knife_config[:bootstrap_no_proxy]}"\n} + end + + if @config[:secret] + client_rb << %Q{encrypted_data_bag_secret "c:/chef/encrypted_data_bag_secret"\n} + end + + unless trusted_certs_script.empty? + client_rb << %Q{trusted_certs_dir "c:/chef/trusted_certs"\n} + end + + if Chef::Config[:fips] + client_rb << <<~CONFIG + fips true + chef_version = ::Chef::VERSION.split(".") + unless chef_version[0].to_i > 12 || (chef_version[0].to_i == 12 && chef_version[1].to_i >= 8) + raise "FIPS Mode requested but not supported by this client" + end + CONFIG + end + + escape_and_echo(client_rb) + end + + def get_log_location + if @chef_config[:config_log_location].equal?(:win_evt) + %Q{:#{@chef_config[:config_log_location]}\n} + elsif @chef_config[:config_log_location].equal?(:syslog) + raise "syslog is not supported for log_location on Windows OS\n" + elsif (@chef_config[:config_log_location].equal?(STDOUT)) + "STDOUT\n" + elsif (@chef_config[:config_log_location].equal?(STDERR)) + "STDERR\n" + elsif @chef_config[:config_log_location].nil? || @chef_config[:config_log_location].empty? + "STDOUT\n" + elsif @chef_config[:config_log_location] + %Q{"#{@chef_config[:config_log_location]}"\n} + else + "STDOUT\n" + end + end + + def start_chef + bootstrap_environment_option = bootstrap_environment.nil? ? '' : " -E #{bootstrap_environment}" + start_chef = "SET \"PATH=%SystemRoot%\\system32;%SystemRoot%;%SystemRoot%\\System32\\Wbem;%SYSTEMROOT%\\System32\\WindowsPowerShell\\v1.0\\;C:\\ruby\\bin;C:\\opscode\\chef\\bin;C:\\opscode\\chef\\embedded\\bin\"\n" + start_chef << "chef-client -c c:/chef/client.rb -j c:/chef/first-boot.json#{bootstrap_environment_option}\n" + end + + def latest_current_windows_chef_version_query + installer_version_string = nil + if @config[:prerelease] + installer_version_string = "&prerelease=true" + else + chef_version_string = if knife_config[:bootstrap_version] + knife_config[:bootstrap_version] + else + Chef::VERSION.split(".").first + end + + installer_version_string = "&v=#{chef_version_string}" + + # If bootstrapping a pre-release version add the prerelease query string + if chef_version_string.split(".").length > 3 + installer_version_string << "&prerelease=true" + end + end + + installer_version_string + end + + def win_wget + # I tried my best to figure out how to properly url decode and switch / to \ + # but this is VBScript - so I don't really care that badly. + win_wget = <<~WGET + url = WScript.Arguments.Named("url") + path = WScript.Arguments.Named("path") + proxy = null + '* Vaguely attempt to handle file:// scheme urls by url unescaping and switching all + '* / into \. Also assume that file:/// is a local absolute path and that file://<foo> + '* is possibly a network file path. + If InStr(url, "file://") = 1 Then + url = Unescape(url) + If InStr(url, "file:///") = 1 Then + sourcePath = Mid(url, Len("file:///") + 1) + Else + sourcePath = Mid(url, Len("file:") + 1) + End If + sourcePath = Replace(sourcePath, "/", "\\") + + Set objFSO = CreateObject("Scripting.FileSystemObject") + If objFSO.Fileexists(path) Then objFSO.DeleteFile path + objFSO.CopyFile sourcePath, path, true + Set objFSO = Nothing + + Else + Set objXMLHTTP = CreateObject("MSXML2.ServerXMLHTTP") + Set wshShell = CreateObject( "WScript.Shell" ) + Set objUserVariables = wshShell.Environment("USER") + + rem http proxy is optional + rem attempt to read from HTTP_PROXY env var first + On Error Resume Next + + If NOT (objUserVariables("HTTP_PROXY") = "") Then + proxy = objUserVariables("HTTP_PROXY") + + rem fall back to named arg + ElseIf NOT (WScript.Arguments.Named("proxy") = "") Then + proxy = WScript.Arguments.Named("proxy") + End If + + If NOT isNull(proxy) Then + rem setProxy method is only available on ServerXMLHTTP 6.0+ + Set objXMLHTTP = CreateObject("MSXML2.ServerXMLHTTP.6.0") + objXMLHTTP.setProxy 2, proxy + End If + + On Error Goto 0 + + objXMLHTTP.open "GET", url, false + objXMLHTTP.send() + If objXMLHTTP.Status = 200 Then + Set objADOStream = CreateObject("ADODB.Stream") + objADOStream.Open + objADOStream.Type = 1 + objADOStream.Write objXMLHTTP.ResponseBody + objADOStream.Position = 0 + Set objFSO = Createobject("Scripting.FileSystemObject") + If objFSO.Fileexists(path) Then objFSO.DeleteFile path + Set objFSO = Nothing + objADOStream.SaveToFile path + objADOStream.Close + Set objADOStream = Nothing + End If + Set objXMLHTTP = Nothing + End If + WGET + escape_and_echo(win_wget) + end + + def win_wget_ps + win_wget_ps = <<~WGET_PS + param( + [String] $remoteUrl, + [String] $localPath + ) + + $ProxyUrl = $env:http_proxy; + $webClient = new-object System.Net.WebClient; + + if ($ProxyUrl -ne '') { + $WebProxy = New-Object System.Net.WebProxy($ProxyUrl,$true) + $WebClient.Proxy = $WebProxy + } + + $webClient.DownloadFile($remoteUrl, $localPath); + WGET_PS + + escape_and_echo(win_wget_ps) + end + + def install_chef + # The normal install command uses regular double quotes in + # the install command, so request such a string from install_command + install_chef = install_command('"') + "\n" + fallback_install_task_command + end + + def bootstrap_directory + bootstrap_directory = "C:\\chef" + end + + def local_download_path + local_download_path = "%TEMP%\\chef-client-latest.msi" + end + + def msi_url(machine_os=nil, machine_arch=nil, download_context=nil) + # The default msi path has a number of url query parameters - we attempt to substitute + # such parameters in as long as they are provided by the template. + + if @config[:install].nil? || @config[:msi_url].empty? + url = "https://www.chef.io/chef/download?p=windows" + url += "&pv=#{machine_os}" unless machine_os.nil? + url += "&m=#{machine_arch}" unless machine_arch.nil? + url += "&DownloadContext=#{download_context}" unless download_context.nil? + url += latest_current_windows_chef_version_query + else + @config[:msi_url] + end + end + + def first_boot + escape_and_echo(super.to_json) + end + + # escape WIN BATCH special chars + # and prefixes each line with an + # echo + def escape_and_echo(file_contents) + file_contents.gsub(/^(.*)$/, 'echo.\1').gsub(/([(<|>)^])/, '^\1') + end + + private + + def install_command(executor_quote) + if @config[:install_as_service] + "msiexec /qn /log #{executor_quote}%CHEF_CLIENT_MSI_LOG_PATH%#{executor_quote} /i #{executor_quote}%LOCAL_DESTINATION_MSI_PATH%#{executor_quote} ADDLOCAL=#{executor_quote}ChefClientFeature,ChefServiceFeature#{executor_quote}" + else + "msiexec /qn /log #{executor_quote}%CHEF_CLIENT_MSI_LOG_PATH%#{executor_quote} /i #{executor_quote}%LOCAL_DESTINATION_MSI_PATH%#{executor_quote}" + end + end + + # Returns a string for copying the trusted certificates on the workstation to the system being bootstrapped + # This string should contain both the commands necessary to both create the files, as well as their content + def trusted_certs_content + content = "" + if @chef_config[:trusted_certs_dir] + Dir.glob(File.join(Chef::Util::PathHelper.escape_glob_dir(@chef_config[:trusted_certs_dir]), "*.{crt,pem}")).each do |cert| + content << "> #{bootstrap_directory}/trusted_certs/#{File.basename(cert)} (\n" + + escape_and_echo(IO.read(File.expand_path(cert))) + "\n)\n" + end + end + content + end + + def client_d_content + content = "" + if @chef_config[:client_d_dir] && File.exist?(@chef_config[:client_d_dir]) + root = Pathname(@chef_config[:client_d_dir]) + root.find do |f| + relative = f.relative_path_from(root) + if f != root + file_on_node = "#{bootstrap_directory}/client.d/#{relative}".gsub("/","\\") + if f.directory? + content << "mkdir #{file_on_node}\n" + else + content << "> #{file_on_node} (\n" + + escape_and_echo(IO.read(File.expand_path(f))) + "\n)\n" + end + end + end + end + content + end + + def fallback_install_task_command + # This command will be executed by schtasks.exe in the batch + # code below. To handle tasks that contain arguments that + # need to be double quoted, schtasks allows the use of single + # quotes that will later be converted to double quotes + command = install_command('\'') + <<~EOH + @set MSIERRORCODE=!ERRORLEVEL! + @if ERRORLEVEL 1 ( + @echo WARNING: Failed to install Chef Client MSI package in remote context with status code !MSIERRORCODE!. + @echo WARNING: This may be due to a defect in operating system update KB2918614: http://support.microsoft.com/kb/2918614 + @set OLDLOGLOCATION="%CHEF_CLIENT_MSI_LOG_PATH%-fail.log" + @move "%CHEF_CLIENT_MSI_LOG_PATH%" "!OLDLOGLOCATION!" > NUL + @echo WARNING: Saving installation log of failure at !OLDLOGLOCATION! + @echo WARNING: Retrying installation with local context... + @schtasks /create /f /sc once /st 00:00:00 /tn chefclientbootstraptask /ru SYSTEM /rl HIGHEST /tr \"cmd /c #{command} & sleep 2 & waitfor /s %computername% /si chefclientinstalldone\" + + @if ERRORLEVEL 1 ( + @echo ERROR: Failed to create Chef Client installation scheduled task with status code !ERRORLEVEL! > "&2" + ) else ( + @echo Successfully created scheduled task to install Chef Client. + @schtasks /run /tn chefclientbootstraptask + @if ERRORLEVEL 1 ( + @echo ERROR: Failed to execut Chef Client installation scheduled task with status code !ERRORLEVEL!. > "&2" + ) else ( + @echo Successfully started Chef Client installation scheduled task. + @echo Waiting for installation to complete -- this may take a few minutes... + waitfor chefclientinstalldone /t 600 + if ERRORLEVEL 1 ( + @echo ERROR: Timed out waiting for Chef Client package to install + ) else ( + @echo Finished waiting for Chef Client package to install. + ) + @schtasks /delete /f /tn chefclientbootstraptask > NUL + ) + ) + ) else ( + @echo Successfully installed Chef Client package. + ) + EOH + end + end + end + end +end diff --git a/spec/unit/knife/bootstrap_spec.rb b/spec/unit/knife/bootstrap_spec.rb index 258d193cf1..1c6943f2fe 100644 --- a/spec/unit/knife/bootstrap_spec.rb +++ b/spec/unit/knife/bootstrap_spec.rb @@ -22,33 +22,36 @@ Chef::Knife::Bootstrap.load_deps require "net/ssh" describe Chef::Knife::Bootstrap do - before do - allow(ChefConfig).to receive(:windows?) { false } - end + let(:bootstrap_template) { nil } + let(:stderr) { StringIO.new } + let(:bootstrap_cli_options) { [ ] } + let(:base_os) { :linux } + let(:target_host) { double("TargetHost") } + let(:knife) do Chef::Log.logger = Logger.new(StringIO.new) Chef::Config[:knife][:bootstrap_template] = bootstrap_template unless bootstrap_template.nil? k = Chef::Knife::Bootstrap.new(bootstrap_cli_options) - k.merge_configs - 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 + k.merge_configs k end - let(:stderr) { StringIO.new } - - let(:bootstrap_template) { nil } - - let(:bootstrap_cli_options) { [ ] } + before do + allow(target_host).to receive(:base_os).and_return base_os + end - it "should use chef-full as default template" do - expect(knife.bootstrap_template).to be_a_kind_of(String) - expect(File.basename(knife.bootstrap_template)).to eq("chef-full") + context "#bootstrap_template" do + it "should default to chef-full" do + expect(knife.bootstrap_template).to be_a_kind_of(String) + expect(File.basename(knife.bootstrap_template)).to eq("chef-full") + end end - context "when using the chef-full default template" do + context "#render_template - when using the chef-full default template" do let(:rendered_template) do knife.merge_configs knife.render_template @@ -284,14 +287,14 @@ describe Chef::Knife::Bootstrap do jsonfile.close end - context "when --json-attributes and --json-attribute-file were both passed" do - it "raises a Chef::Exceptions::BootstrapCommandInputError with the proper error message" do - knife.parse_options(["-j", '{"foo":{"bar":"baz"}}']) - knife.parse_options(["--json-attribute-file", jsonfile.path]) - knife.merge_configs - expect { knife.run }.to raise_error(Chef::Exceptions::BootstrapCommandInputError) - jsonfile.close - end + it "raises a Chef::Exceptions::BootstrapCommandInputError with the proper error message" do + knife.parse_options(["-j", '{"foo":{"bar":"baz"}}']) + knife.parse_options(["--json-attribute-file", jsonfile.path]) + knife.merge_configs + allow(knife).to receive(:validate_name_args!) + + expect { knife.run }.to raise_error(Chef::Exceptions::BootstrapCommandInputError) + jsonfile.close end end end @@ -317,13 +320,16 @@ 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 k.parse_options(options) k.merge_configs k end let(:options) { ["--bootstrap-no-proxy", setting, "-s", "foo"] } + let(:template_file) { File.expand_path(File.join(CHEF_SPEC_DATA, "bootstrap", "no_proxy.erb")) } + let(:rendered_template) do knife.render_template end @@ -506,48 +512,153 @@ describe Chef::Knife::Bootstrap do context "when client_d_dir is set" do let(:client_d_dir) do Chef::Util::PathHelper.cleanpath( - File.join(File.dirname(__FILE__), "../../data/client.d_00")) end + File.join(File.dirname(__FILE__), "../../data/client.d_00")) end + + it "creates /etc/chef/client.d" do + expect(rendered_template).to match("mkdir -p /etc/chef/client\.d") + end + + context "a flat directory structure" do + it "escapes single-quotes" do + expect(rendered_template).to match("cat > /etc/chef/client.d/02-strings.rb <<'EOP'") + expect(rendered_template).to match("something '\\\\''/foo/bar'\\\\''") + end + + it "creates a file 00-foo.rb" do + expect(rendered_template).to match("cat > /etc/chef/client.d/00-foo.rb <<'EOP'") + expect(rendered_template).to match("d6f9b976-289c-4149-baf7-81e6ffecf228") + end + it "creates a file bar" do + expect(rendered_template).to match("cat > /etc/chef/client.d/bar <<'EOP'") + expect(rendered_template).to match("1 / 0") + end + end + + context "a nested directory structure" do + let(:client_d_dir) do + Chef::Util::PathHelper.cleanpath( + File.join(File.dirname(__FILE__), "../../data/client.d_01")) end + it "creates a file foo/bar.rb" do + expect(rendered_template).to match("cat > /etc/chef/client.d/foo/bar.rb <<'EOP'") + expect(rendered_template).to match("1 / 0") + end + end + end + end + + + + describe "#connection_protocol" do + let(:host_descriptor) { "example.com" } + let(:config) { { } } + let(:knife_connection_protocol) { nil } + before do + allow(knife).to receive(:config).and_return config + allow(knife).to receive(:host_descriptor).and_return host_descriptor + if knife_connection_protocol + Chef::Config[:knife][:connection_protocol] = knife_connection_protocol + end + end + + context "when protocol is part of the host argument" do + let(:host_descriptor) { "winrm://myhost" } + + it "returns the value provided by the host argument" do + expect(knife.connection_protocol).to eq "winrm" + end + end + + context "when protocol is provided via the CLI flag" do + let(:config) { { connection_protocol: "winrm" } } + it "returns that value" do + expect(knife.connection_protocol).to eq "winrm" + end + + + end + context "when protocol is provided via the host argument and the CLI flag" do + let(:host_descriptor) { "ssh://example.com" } + let(:config) { { connection_protocol: "winrm" } } - it "creates /etc/chef/client.d" do - expect(rendered_template).to match("mkdir -p /etc/chef/client\.d") + it "returns the value provided by the host argument" do + expect(knife.connection_protocol).to eq "ssh" end + end - context "a flat directory structure" do - it "escapes single-quotes" do - expect(rendered_template).to match("cat > /etc/chef/client.d/02-strings.rb <<'EOP'") - expect(rendered_template).to match("something '\\\\''/foo/bar'\\\\''") + context "when no explicit protocol is provided" do + let(:config) { {} } + let(:host_descriptor) { "example.com" } + let(:knife_connection_protocol) { "winrm" } + it "falls back to knife config" do + expect(knife.connection_protocol).to eq "winrm" + end + context "and there is no knife bootstrap_protocol" do + let(:knife_connection_protocol) { nil } + it "falls back to 'ssh'" do + expect(knife.connection_protocol).to eq "ssh" end + end + end - it "creates a file 00-foo.rb" do - expect(rendered_template).to match("cat > /etc/chef/client.d/00-foo.rb <<'EOP'") - expect(rendered_template).to match("d6f9b976-289c-4149-baf7-81e6ffecf228") + end + + describe "#validate_protocol!" do + let(:host_descriptor) { "example.com" } + let(:config) { { } } + let(:connection_protocol) { "ssh" } + before do + allow(knife).to receive(:config).and_return config + allow(knife).to receive(:connection_protocol).and_return connection_protocol + allow(knife).to receive(:host_descriptor).and_return host_descriptor + end + + context "when protocol is provided both in the URL and via --protocol" do + + context "and they do not match" do + let(:connection_protocol) { "ssh" } + let(:config) { { connection_protocol: "winrm" } } + it "outputs an error and exits" do + expect(knife.ui).to receive(:error) + expect{ knife.validate_protocol! }.to raise_error SystemExit end - it "creates a file bar" do - expect(rendered_template).to match("cat > /etc/chef/client.d/bar <<'EOP'") - expect(rendered_template).to match("1 / 0") + end + + context "and they do match" do + let(:connection_protocol) { "winrm" } + let(:config) { { connection_protocol: "winrm" } } + it "returns true" do + expect(knife.validate_protocol!).to eq true end end + end - context "a nested directory structure" do - let(:client_d_dir) do - Chef::Util::PathHelper.cleanpath( - File.join(File.dirname(__FILE__), "../../data/client.d_01")) end - it "creates a file foo/bar.rb" do - expect(rendered_template).to match("cat > /etc/chef/client.d/foo/bar.rb <<'EOP'") - expect(rendered_template).to match("1 / 0") + context "and the protocol is supported" do + + Chef::Knife::Bootstrap::SUPPORTED_CONNECTION_PROTOCOLS.each do |proto| + let(:connection_protocol) { proto } + it "returns true for #{proto}" do + expect(knife.validate_protocol!).to eq true end end end + + context "and the protocol is not supported" do + let(:connection_protocol) { "invalid" } + it "outputs an error and exits" do + expect(knife.ui).to receive(:error).with(/Unsupported protocol '#{connection_protocol}'/) + expect{ knife.validate_protocol! }.to raise_error SystemExit + end + end end - describe "handling policyfile options" do + describe "#validate_policy_options!" do context "when only policy_name is given" do let(:bootstrap_cli_options) { %w{ --policy-name my-app-server } } it "returns an error stating that policy_name and policy_group must be given together" do - expect { knife.validate_options! }.to raise_error(SystemExit) + expect { knife.validate_policy_options! }.to raise_error(SystemExit) expect(stderr.string).to include("ERROR: --policy-name and --policy-group must be specified together") end @@ -558,7 +669,7 @@ describe Chef::Knife::Bootstrap do let(:bootstrap_cli_options) { %w{ --policy-group staging } } it "returns an error stating that policy_name and policy_group must be given together" do - expect { knife.validate_options! }.to raise_error(SystemExit) + expect { knife.validate_policy_options! }.to raise_error(SystemExit) expect(stderr.string).to include("ERROR: --policy-name and --policy-group must be specified together") end @@ -569,7 +680,7 @@ describe Chef::Knife::Bootstrap do let(:bootstrap_cli_options) { %w{ --policy-name my-app --policy-group staging --run-list cookbook } } it "returns an error stating that policyfile and run_list are exclusive" do - expect { knife.validate_options! }.to raise_error(SystemExit) + expect { knife.validate_policy_options! }.to raise_error(SystemExit) expect(stderr.string).to include("ERROR: Policyfile options and --run-list are exclusive") end @@ -580,7 +691,7 @@ describe Chef::Knife::Bootstrap do let(:bootstrap_cli_options) { %w{ --policy-name my-app --policy-group staging } } it "passes options validation" do - expect { knife.validate_options! }.to_not raise_error + expect { knife.validate_policy_options! }.to_not raise_error end it "passes them into the bootstrap context" do @@ -598,267 +709,1319 @@ describe Chef::Knife::Bootstrap do # Arguably a bug in the plugin: it shouldn't be setting this to nil, but it # worked before, so make it work now. context "when a plugin sets the run list option to nil" do - before do knife.config[:run_list] = nil end it "passes options validation" do - expect { knife.validate_options! }.to_not raise_error + expect { knife.validate_policy_options! }.to_not raise_error end + end + end + # TODO - this is the only cli option we validate the _option_ itself - + # so we'll know if someone accidentally deletes or renames use_sudo_password + # Is this worht keeping? If so, then it seems we should expand it + # to cover all options. + context "validating use_sudo_password option" do + it "use_sudo_password contains description and long params for help" do + expect(knife.options).to(have_key(:use_sudo_password)) \ + && expect(knife.options[:use_sudo_password][:description].to_s).not_to(eq(""))\ + && expect(knife.options[:use_sudo_password][:long].to_s).not_to(eq("")) end + end + + + context "#connection_opts" do + let(:connection_protocol) { "ssh" } + before do + allow(knife).to receive(:connection_protocol).and_return connection_protocol + end + context "behavioral test: " do + let(:expected_connection_opts) { + { base_opts: true, + ssh_identity_opts: true, + ssh_opts: true, + gateway_opts: true, + host_verify_opts: true, + sudo_opts: true, + winrm_opts: true } + } + + it "queries and merges only expected configurations" do + expect(knife).to receive(:base_opts).and_return({ base_opts: true }) + expect(knife).to receive(:host_verify_opts).and_return({ host_verify_opts: true }) + expect(knife).to receive(:gateway_opts).and_return({ gateway_opts: true }) + expect(knife).to receive(:sudo_opts).and_return({ sudo_opts: true }) + expect(knife).to receive(:winrm_opts).and_return({ winrm_opts: true }) + expect(knife).to receive(:ssh_opts).and_return({ ssh_opts: true }) + expect(knife).to receive(:ssh_identity_opts).and_return({ ssh_identity_opts: true }) + expect(knife.connection_opts).to match expected_connection_opts + end + end + + context "functional test: " do + context "when protocol is winrm" do + let(:connection_protocol) { "winrm" } + # context "and neither CLI nor Chef::Config config entries have been provided" + # end + context "and all supported values are provided as Chef::Config entries" do + before do + # Set everything to easily identifiable and obviously fake values + # to verify that Chef::Config is being sourced instead of knife.config + Chef::Config[:knife][:max_wait] = 9999 + Chef::Config[:knife][:winrm_user] = "winbob" + Chef::Config[:knife][:winrm_port] = 9999 + Chef::Config[:knife][:ca_trust_file] = "trust.me" + Chef::Config[:knife][:kerberos_realm] = "realm" + Chef::Config[:knife][:kerberos_service] = "service" + Chef::Config[:knife][:winrm_auth_method] = "kerberos" # default is negotiate + Chef::Config[:knife][:winrm_basic_auth_only] = true + Chef::Config[:knife][:winrm_no_verify_cert] = true + Chef::Config[:knife][:winrm_session_timeout] = 9999 + Chef::Config[:knife][:winrm_ssl] = true + Chef::Config[:knife][:winrm_ssl_peer_fingerprint] = "ABCDEF" + end + + context "and unsupported Chef::Config options are given in Chef::Config, not in CLI" do + before do + Chef::Config[:knife][:password] = "blah" + Chef::Config[:knife][:winrm_password] = "blah" + end + it "does not include the corresponding option in the connection options" do + expect(knife.connection_opts.key?(:password)).to eq false + end + end + + context "and no CLI options have been given" do + before do + knife.config = {} + end + let(:expected_result) { + { + logger: Chef::Log, # not configurable + ca_trust_file: "trust.me", + max_wait_until_ready: 9999, + operation_timeout: 9999, + ssl_peer_fingerprint: "ABCDEF", + winrm_transport: "kerberos", + winrm_basic_auth_only: true, + user: "winbob", + port: 9999, + self_signed: true, + ssl: true, + kerberos_realm: "realm", + kerberos_service: "service" + } + } + + it "generates a config hash using the Chef::Config values" do + expect(knife.connection_opts).to match expected_result + end + + end + + context "and some CLI options have been given" do + let(:expected_result) { + { + logger: Chef::Log, # not configurable + ca_trust_file: "no trust", + max_wait_until_ready: 9999, + operation_timeout: 9999, + ssl_peer_fingerprint: "ABCDEF", + winrm_transport: "kerberos", + winrm_basic_auth_only: true, + user: "microsoftbob", + port: 12, + self_signed: true, + ssl: true, + kerberos_realm: "realm", + kerberos_service: "service", + password: "lobster" + } + } + + before do + knife.config[:ca_trust_file] = "no trust" + knife.config[:connection_user] = "microsoftbob" + knife.config[:connection_port] = 12 + knife.config[:winrm_port] = "13" # indirectly verify we're not looking for the wrong CLI flag + knife.config[:password] = "lobster" + end + + it "generates a config hash using the CLI options when available and falling back to Chef::Config values" do + expect(knife.connection_opts).to match expected_result + end + end + + context "and all CLI options have been given" do + before do + # We'll force kerberos vi knife.config because it + # causes additional options to populate - make sure + # Chef::Config is different so we can be sure that we didn't + # pull in the Chef::Config value + Chef::Config[:knife][:winrm_auth_method] = "negotiate" + knife.config[:password] = "blue" + knife.config[:max_wait] = 1000 + knife.config[:connection_user] = "clippy" + knife.config[:connection_port] = 1000 + knife.config[:winrm_port] = 1001 # We should not see this value get used + + knife.config[:ca_trust_file] = "trust.the.internet" + knife.config[:kerberos_realm] = "otherrealm" + knife.config[:kerberos_service] = "otherservice" + knife.config[:winrm_auth_method] = "kerberos" # default is negotiate + knife.config[:winrm_basic_auth_only] = false + knife.config[:winrm_no_verify_cert] = false + knife.config[:winrm_session_timeout] = 1000 + knife.config[:winrm_ssl] = false + knife.config[:winrm_ssl_peer_fingerprint] = "FEDCBA" + end + let(:expected_result) { + { + logger: Chef::Log, # not configurable + ca_trust_file: "trust.the.internet", + max_wait_until_ready: 1000, + operation_timeout: 1000, + ssl_peer_fingerprint: "FEDCBA", + winrm_transport: "kerberos", + winrm_basic_auth_only: false, + user: "clippy", + port: 1000, + self_signed: false, + ssl: false, + kerberos_realm: "otherrealm", + kerberos_service: "otherservice", + password: "blue" + } + } + it "generates a config hash using the CLI options and pulling nothing from Chef::Config" do + expect(knife.connection_opts).to match expected_result + end + end + end # with underlying Chef::Config values + + context "and no values are provided from Chef::Config or CLI" do + before do + knife.config = {} + end + let(:expected_result) { + { + logger: Chef::Log, + operation_timeout: 60, + self_signed: false, + ssl: false, + ssl_peer_fingerprint: nil, + winrm_basic_auth_only: false, + winrm_transport: "negotiate" + } + } + it "populates appropriate defaults" do + expect(knife.connection_opts).to match expected_result + end + end + end # winrm + + context "when protocol is ssh" do + let(:connection_protocol) { "ssh" } + # context "and neither CLI nor Chef::Config config entries have been provided" + # end + context "and all supported values are provided as Chef::Config entries" do + before do + # Set everything to easily identifiable and obviously fake values + # to verify that Chef::Config is being sourced instead of knife.config + Chef::Config[:knife][:max_wait] = 9999 + Chef::Config[:knife][:ssh_user] = "sshbob" + Chef::Config[:knife][:ssh_port] = 9999 + Chef::Config[:knife][:host_key_verify] = false + Chef::Config[:knife][:ssh_gateway_identity] = "/gateway.pem" + Chef::Config[:knife][:ssh_gateway] = "admin@mygateway.local:1234" + Chef::Config[:knife][:ssh_identity_file] = "/identity.pem" + Chef::Config[:knife][:use_sudo_password] = false # We have no password. + end + + context "and no CLI options have been given" do + before do + knife.config = {} + end + let(:expected_result) { + { + logger: Chef::Log, # not configurable + max_wait_until_ready: 9999, + user: "sshbob", + bastion_host: "mygateway.local", + bastion_port: 1234, + bastion_user: "admin", + forward_agent: false, + keys_only: true, + key_files: ['/identity.pem', '/gateway.pem'], + sudo: false, + verify_host_key: false, + port: 9999 + } + } + + it "generates a correct config hash using the Chef::Config values" do + expect(knife.connection_opts).to match expected_result + end + end + + context "and unsupported Chef::Config options are given in Chef::Config, not in CLI" do + before do + Chef::Config[:knife][:password] = "blah" + Chef::Config[:knife][:ssh_password] = "blah" + Chef::Config[:knife][:preserve_home] = true + Chef::Config[:knife][:use_sudo] = true + Chef::Config[:knife][:ssh_forward_agent] = "blah" + end + it "does not include the corresponding option in the connection options" do + expect(knife.connection_opts.key?(:password)).to eq false + expect(knife.connection_opts.key?(:ssh_forward_agent)).to eq false + expect(knife.connection_opts.key?(:use_sudo)).to eq false + expect(knife.connection_opts.key?(:preserve_home)).to eq false + end + end + + context "and some CLI options have been given" do + before do + knife.config[:connection_user] = "sshalice" + knife.config[:connection_port] = 12 + knife.config[:ssh_port] = "13" # canary to indirectly verify we're not looking for the wrong CLI flag + knife.config[:password] = "feta cheese" + knife.config[:max_wait] = 150 + knife.config[:use_sudo] = true + knife.config[:use_sudo_pasword] = true + knife.config[:ssh_forward_agent] = true + end + + let(:expected_result) { + { + logger: Chef::Log, # not configurable + max_wait_until_ready: 150, #cli + user: "sshalice", # cli + password: "feta cheese", # cli + bastion_host: "mygateway.local", # Config + bastion_port: 1234, # Config + bastion_user: "admin", # Config + forward_agent: true, # cli + keys_only: false, # implied false from config password present + key_files: ['/identity.pem', '/gateway.pem'], # Config + sudo: true, # ccli + verify_host_key: false, # Config + port: 12 # cli + } + } + + + it "generates a config hash using the CLI options when available and falling back to Chef::Config values" do + expect(knife.connection_opts).to match expected_result + end + end + + context "and all CLI options have been given" do + before do + knife.config[:max_wait] = 150 + knife.config[:connection_user] = "sshroot" + knife.config[:connection_port] = 1000 + knife.config[:password] = "blah" + knife.config[:forward_agent] = true + knife.config[:use_sudo] = true + knife.config[:use_sudo_password] = true + knife.config[:preserve_home] = true + knife.config[:use_sudo_pasword] = true + knife.config[:ssh_forward_agent] = true + knife.config[:ssh_verify_host_key] = true + knife.config[:ssh_gateway_identity] = "/gateway-identity.pem" + knife.config[:ssh_gateway] = "me@example.com:10" + knife.config[:ssh_identity_file] = "/my-identity.pem" + + # We'll set these as canaries - if one of these values shows up + # in a failed test, then the behavior of not pulling from these keys + # out of knife.config is broken: + knife.config[:ssh_user] = "do not use" + knife.config[:ssh_port] = 1001 + end + let(:expected_result) { + { + logger: Chef::Log, # not configurable + max_wait_until_ready: 150, + user: "sshroot", + password: "blah", + port: 1000, + bastion_host: "example.com", + bastion_port: 10, + bastion_user: "me", + forward_agent: true, + keys_only: false, + key_files: ['/my-identity.pem', '/gateway-identity.pem'], + sudo: true, + sudo_options: "-H", + sudo_password: "blah", + verify_host_key: true, + } + } + it "generates a config hash using the CLI options and pulling nothing from Chef::Config" do + expect(knife.connection_opts).to match expected_result + end + end + end + context "and no values are provided from Chef::Config or CLI" do + before do + knife.config = {} + end + let(:expected_result) { + { + forward_agent: false, + key_files: [], + logger: Chef::Log, + keys_only: false, + sudo: false, + verify_host_key: true + } + } + it "populates appropriate defaults" do + expect(knife.connection_opts).to match expected_result + end + end + + end #ssh + end #functional tests + + end #connection_opts + + context "#base_opts" do + let(:connection_protocol) { nil } + + before do + allow(knife).to receive(:connection_protocol).and_return connection_protocol + end + + context "when determining knife config keys for user and port" do + let(:connection_protocol) { "fake" } + it "uses the protocol name to resolve the knife config keys" do + allow(knife).to receive(:config_value).with(:max_wait) + + expect(knife).to receive(:config_value).with(:connection_port, :fake_port) + expect(knife).to receive(:config_value).with(:connection_user, :fake_user) + knife.base_opts + end + end + + context "for all protocols" do + context "when password is provided" do + before do + knife.config[:connection_port] = 250 + knife.config[:connection_user] = "test" + knife.config[:password] = "opscode" + end + + let(:expected_opts) { + { + port: 250, + user: "test", + logger: Chef::Log, + password: "opscode" + } + } + it "generates the correct options" do + expect(knife.base_opts).to eq expected_opts + end + + end + + context "when password is not provided" do + before do + knife.config[:connection_port] = 250 + knife.config[:connection_user] = "test" + end + + let(:expected_opts) { + { + port: 250, + user: "test", + logger: Chef::Log + } + } + it "generates the correct options" do + expect(knife.base_opts).to eq expected_opts + end + end + end end - describe "when configuring the underlying knife ssh command" do - context "from the command line" do - let(:knife_ssh) do - knife.name_args = ["foo.example.com"] - knife.config[:ssh_user] = "rooty" - knife.config[:ssh_port] = "4001" - knife.config[:ssh_password] = "open_sesame" - Chef::Config[:knife][:ssh_user] = nil - Chef::Config[:knife][:ssh_port] = nil - knife.config[:forward_agent] = true - knife.config[:ssh_identity_file] = "~/.ssh/me.rsa" - allow(knife).to receive(:render_template).and_return("") - knife.knife_ssh + context "#host_verify_opts" do + let(:connection_protocol) { nil } + before do + allow(knife).to receive(:connection_protocol).and_return connection_protocol + end + + context "for winrm" do + let(:connection_protocol) { "winrm" } + it "returns the expected configuration" do + knife.config[:winrm_no_verify_cert] = true + expect(knife.host_verify_opts).to eq( { self_signed: true } ) + end + it "provides a correct default when no option given" do + expect(knife.host_verify_opts).to eq( { self_signed: false } ) end + end - it "configures the hostname" do - expect(knife_ssh.name_args.first).to eq("foo.example.com") + context "for ssh" do + let(:connection_protocol) { "ssh" } + it "returns the expected configuration" do + knife.config[:ssh_verify_host_key] = false + expect(knife.host_verify_opts).to eq( { verify_host_key: false } ) end + it "provides a correct default when no option given" do + expect(knife.host_verify_opts).to eq( { verify_host_key: true } ) + end + end + end + + # TODO - test keys_only, password, config source behavior + context "#ssh_identity_opts" do + let(:connection_protocol) { nil } + before do + allow(knife).to receive(:connection_protocol).and_return connection_protocol + end - it "configures the ssh user" do - expect(knife_ssh.config[:ssh_user]).to eq("rooty") + context "for winrm" do + let(:connection_protocol) { "winrm" } + it "returns an empty hash" do + expect(knife.ssh_identity_opts).to eq({}) end + end + + context "for ssh" do + let(:connection_protocol) { "ssh" } + context "when an identity file is specified" do + before do + knife.config[:ssh_identity_file] = "/identity.pem" + end + it "generates the expected configuration" do + expect(knife.ssh_identity_opts).to eq({ + key_files: [ "/identity.pem" ], + keys_only: true + }) + end + context "and a password is also specified" do + before do + knife.config[:password] = "blah" + end + it "generates the expected configuration (key, keys_only false)" do + expect(knife.ssh_identity_opts).to eq({ + key_files: [ "/identity.pem" ], + keys_only: false + }) + end + end + + context "and a gateway is not specified" do + context "but a gateway identity file is specified" do + it "does not include the gateway identity file in keys" do + expect(knife.ssh_identity_opts).to eq({ + key_files: ["/identity.pem"], + keys_only: true + }) + end - it "configures the ssh password" do - expect(knife_ssh.config[:ssh_password]).to eq("open_sesame") + end + + end + + context "and a gatway is specified" do + before do + knife.config[:ssh_gateway] = "example.com" + end + context "and a gateway identity file is not specified" do + it "config includes only identity file and not gateway identity" do + expect(knife.ssh_identity_opts).to eq({ + key_files: [ "/identity.pem" ], + keys_only: true + }) + end + end + + context "and a gateway identity file is also specified" do + before do + knife.config[:ssh_gateway_identity] = "/gateway.pem" + end + + it "generates the expected configuration (both keys, keys_only true)" do + expect(knife.ssh_identity_opts).to eq({ + key_files: [ "/identity.pem", "/gateway.pem" ], + keys_only: true + }) + end + end + end end - it "configures the ssh port" do - expect(knife_ssh.config[:ssh_port]).to eq("4001") + context "when no identity file is specified" do + it "generates the expected configuration (no keys, keys_only false)" do + expect(knife.ssh_identity_opts).to eq( { + key_files: [ ], + keys_only: false + }) + end + context "and a gateway with gateway identity file is specified" do + before do + knife.config[:ssh_gateway] = "host" + knife.config[:ssh_gateway_identity] = "/gateway.pem" + end + + it "generates the expected configuration (gateway key, keys_only false)" do + expect(knife.ssh_identity_opts).to eq({ + key_files: [ "/gateway.pem" ], + keys_only: false + }) + end + end + end + end + end + + context "#gateway_opts" do + let(:connection_protocol) { nil } + before do + allow(knife).to receive(:connection_protocol).and_return connection_protocol + end + + context "for winrm" do + let(:connection_protocol) { "winrm" } + it "returns an empty hash" do + expect(knife.gateway_opts).to eq({}) + end + end + + context "for ssh" do + let(:connection_protocol) { "ssh" } + context "and ssh_gateway with hostname, user and port provided" do + before do + knife.config[:ssh_gateway] = "testuser@gateway:9021" + end + it "returns a proper bastion host config subset" do + expect(knife.gateway_opts).to eq({ + bastion_user: "testuser", + bastion_host: "gateway", + bastion_port: 9021 + }) + end + end + context "and ssh_gateway with only hostname is given" do + before do + knife.config[:ssh_gateway] = "gateway" + end + it "returns a proper bastion host config subset" do + expect(knife.gateway_opts).to eq({ + bastion_user: nil, + bastion_host: "gateway", + bastion_port: nil + }) + end + end + context "and ssh_gateway with hostname and user is is given" do + before do + knife.config[:ssh_gateway] = "testuser@gateway" + end + it "returns a proper bastion host config subset" do + expect(knife.gateway_opts).to eq({ + bastion_user: "testuser", + bastion_host: "gateway", + bastion_port: nil + }) + end end - it "configures the ssh agent forwarding" do - expect(knife_ssh.config[:forward_agent]).to eq(true) + context "and ssh_gateway with hostname and port is is given" do + before do + knife.config[:ssh_gateway] = "gateway:11234" + end + it "returns a proper bastion host config subset" do + expect(knife.gateway_opts).to eq({ + bastion_user: nil, + bastion_host: "gateway", + bastion_port: 11234 + }) + end end - it "configures the ssh identity file" do - expect(knife_ssh.config[:ssh_identity_file]).to eq("~/.ssh/me.rsa") + context "and ssh_gateway is not provided" do + it "returns an empty hash" do + expect(knife.gateway_opts).to eq({}) + end end end + end - context "validating use_sudo_password" do - before do - knife.config[:ssh_password] = "password" - allow(knife).to receive(:render_template).and_return("") + context "#sudo_opts" do + let(:connection_protocol) { nil } + before do + allow(knife).to receive(:connection_protocol).and_return connection_protocol + end + + context "for winrm" do + let(:connection_protocol) { "winrm" } + it "returns an empty hash" do + expect(knife.sudo_opts).to eq({}) end + end + + context "for ssh" do + let(:connection_protocol) { "ssh" } + context "when use_sudo is set" do + before do + knife.config[:use_sudo] = true + end + + it "returns a config that enables sudo" do + expect(knife.sudo_opts).to eq( { sudo: true} ) + end - it "use_sudo_password contains description and long params for help" do - expect(knife.options).to(have_key(:use_sudo_password)) \ - && expect(knife.options[:use_sudo_password][:description].to_s).not_to(eq(""))\ - && expect(knife.options[:use_sudo_password][:long].to_s).not_to(eq("")) + context "when use_sudo_password is also set" do + before do + knife.config[:use_sudo_password] = true + knife.config[:password] = "opscode" + end + it "includes :password value in a sudo-enabled configuration" do + expect(knife.sudo_opts).to eq({ + sudo: true, + sudo_password: "opscode" + }) + end + end + + context "when preserve_home is set" do + before do + knife.config[:preserve_home] = true + end + it "enables sudo with sudo_option to preserve home" do + expect(knife.sudo_opts).to eq({ + sudo_options: "-H", + sudo: true + }) + end + end end - it "uses the password from --ssh-password for sudo when --use-sudo-password is set" do - knife.config[:use_sudo] = true - knife.config[:use_sudo_password] = true - expect(knife.ssh_command).to include("echo \'#{knife.config[:ssh_password]}\' | sudo -S") + context "when use_sudo is not set" do + before do + knife.config[:use_sudo_password] = true + knife.config[:preserve_home] = true + end + it "returns configuration for sudo off, ignoring other related options" do + expect(knife.sudo_opts).to eq( { sudo: false} ) + end end + end + end + + context "#ssh_opts" do + let(:connection_protocol) { nil } + before do + allow(knife).to receive(:connection_protocol).and_return connection_protocol + end - it "should not honor --use-sudo-password when --use-sudo is not set" do - knife.config[:use_sudo] = false - knife.config[:use_sudo_password] = true - expect(knife.ssh_command).not_to include("echo #{knife.config[:ssh_password]} | sudo -S") + context "for ssh" do + let(:connection_protocol) { "ssh" } + context "when ssh_forward_agent has a value" do + before do + knife.config[:ssh_forward_agent] = true + end + it "returns a configuration hash with forward_agent set to true" do + expect(knife.ssh_opts).to eq({ forward_agent: true }) + end + end + context "when ssh_forward_agent is not set" do + it "returns a configuration hash with forward_agent set to false" do + expect(knife.ssh_opts).to eq({ forward_agent: false }) + end end end - context "from the knife config file" do - let(:knife_ssh) do - knife.name_args = ["config.example.com"] - Chef::Config[:knife][:ssh_user] = "curiosity" - Chef::Config[:knife][:ssh_port] = "2430" - Chef::Config[:knife][:forward_agent] = true - Chef::Config[:knife][:ssh_identity_file] = "~/.ssh/you.rsa" - Chef::Config[:knife][:ssh_gateway] = "towel.blinkenlights.nl" - Chef::Config[:knife][:ssh_gateway_identity] = "~/.ssh/gateway.rsa" - Chef::Config[:knife][:host_key_verify] = true - allow(knife).to receive(:render_template).and_return("") - knife.config = {} - knife.merge_configs - knife.knife_ssh + context "for winrm" do + let(:connection_protocol) { "winrm" } + it "returns an empty has because ssh is not winrm" do + expect(knife.ssh_opts).to eq({}) end + end - it "configures the ssh user" do - expect(knife_ssh.config[:ssh_user]).to eq("curiosity") + end + + context "#winrm_opts" do + let(:connection_protocol) { nil } + before do + allow(knife).to receive(:connection_protocol).and_return connection_protocol + end + + context "for winrm" do + let(:connection_protocol) { "winrm" } + let(:expected) { { + winrm_transport: "negotiate", + winrm_basic_auth_only: false, + ssl: false, + ssl_peer_fingerprint: nil, + operation_timeout: 60, + }} + + it "generates a correct configuration hash with expected defaults" do + expect(knife.winrm_opts).to eq expected end - it "configures the ssh port" do - expect(knife_ssh.config[:ssh_port]).to eq("2430") + context "with ssl_peer_fingerprint" do + let(:ssl_peer_fingerprint_expected) { + expected.merge({ ssl_peer_fingerprint: "ABCD"}) + } + + before do + knife.config[:winrm_ssl_peer_fingerprint] = "ABCD" + end + + it "generates a correct options hash with ssl_peer_fingerprint from the config provided" do + expect(knife.winrm_opts).to eq ssl_peer_fingerprint_expected + end end - it "configures the ssh agent forwarding" do - expect(knife_ssh.config[:forward_agent]).to eq(true) + context "with winrm_ssl" do + let(:ssl_expected) { + expected.merge({ ssl: true }) + } + before do + knife.config[:winrm_ssl] = true + end + + it "generates a correct options hash with ssl from the config provided" do + expect(knife.winrm_opts).to eq ssl_expected + end end - it "configures the ssh identity file" do - expect(knife_ssh.config[:ssh_identity_file]).to eq("~/.ssh/you.rsa") + context "with winrm_auth_method" do + let(:winrm_auth_method_expected) { + expected.merge({ winrm_transport: "freeaccess" }) + } + + before do + knife.config[:winrm_auth_method] = "freeaccess" + end + + it "generates a correct options hash with winrm_transport from the config provided" do + expect(knife.winrm_opts).to eq winrm_auth_method_expected + end end - it "configures the ssh gateway" do - expect(knife_ssh.config[:ssh_gateway]).to eq("towel.blinkenlights.nl") + context "with ca_trust_file" do + let(:ca_trust_expected) { + expected.merge({ ca_trust_file: "/trust.me"}) + } + before do + knife.config[:ca_trust_file] = "/trust.me" + end + + it "generates a correct options hash with ca_trust_file from the config provided" do + expect(knife.winrm_opts).to eq ca_trust_expected + end end - it "configures the ssh gateway identity" do - expect(knife_ssh.config[:ssh_gateway_identity]).to eq("~/.ssh/gateway.rsa") + context "with kerberos auth" do + let(:kerberos_expected) { + expected.merge({ + kerberos_service: "testsvc", + kerberos_realm: "TESTREALM", + winrm_transport: "kerberos" + }) + } + + before do + knife.config[:winrm_auth_method] = "kerberos" + knife.config[:kerberos_service] = "testsvc" + knife.config[:kerberos_realm] = "TESTREALM" + end + + it "generates a correct options hash containing kerberos auth configuration from the config provided" do + expect(knife.winrm_opts).to eq kerberos_expected + end end - it "configures the host key verify mode" do - expect(knife_ssh.config[:host_key_verify]).to eq(true) + context "with winrm_basic_auth_only" do + before do + knife.config[:winrm_basic_auth_only] = true + end + let(:basic_auth_expected) { + expected.merge( { winrm_basic_auth_only: true } ) + } + it "generates a correct options hash containing winrm_basic_auth_only from the config provided" do + expect(knife.winrm_opts).to eq basic_auth_expected + end end end - describe "when falling back to password auth when host key auth fails" do - let(:knife_ssh_with_password_auth) do - knife.name_args = ["foo.example.com"] - knife.config[:ssh_user] = "rooty" - knife.config[:ssh_identity_file] = "~/.ssh/me.rsa" - allow(knife).to receive(:render_template).and_return("") - k = knife.knife_ssh - allow(k).to receive(:get_password).and_return("typed_in_password") - allow(knife).to receive(:knife_ssh).and_return(k) - knife.knife_ssh_with_password_auth + context "for ssh" do + let(:connection_protocol) { "ssh" } + it "returns an empty hash because ssh is not winrm" do + expect(knife.winrm_opts).to eq({}) end + end + end + describe "#run" do + before do + allow(knife.client_builder).to receive(:client_path).and_return("/key.pem") + end + + it "performs the steps we expect to run a bootstrap" do + expect(knife).to receive(:validate_name_args!).ordered + expect(knife).to receive(:validate_protocol!).ordered + expect(knife).to receive(:validate_first_boot_attributes!).ordered + expect(knife).to receive(:validate_winrm_transport_opts!).ordered + expect(knife).to receive(:validate_policy_options!).ordered + expect(knife).to receive(:winrm_warn_no_ssl_verification).ordered + expect(knife).to receive(:register_client).ordered + expect(knife).to receive(:connect!).ordered + 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 + + knife.run + + # Post-run verify expected state changes (not many directly in #run) + expect(knife.bootstrap_context.client_pem).to eq "/key.pem" + expect($stdout.sync).to eq true + end + end + + describe "#register_client" do + let(:vault_handler_mock) { double("ChefVaultHandler") } + let(:client_builder_mock) { double("ClientBuilder") } + let(:node_name) { nil } + before do + allow(knife).to receive(:chef_vault_handler).and_return vault_handler_mock + allow(knife).to receive(:client_builder).and_return client_builder_mock + knife.config[:chef_node_name] = node_name + end + + shared_examples_for "creating the client locally" do + context "when a valid node name is present" do + let(:node_name) { "test" } + before do + allow(client_builder_mock).to receive(:client).and_return "client" + end + + it "runs client_builder and vault_handler" do + expect(client_builder_mock).to receive(:run) + expect(vault_handler_mock).to receive(:run).with("client") + knife.register_client + end + end + + context "when no valid node name is present" do + let(:node_name) { nil } + it "shows an error and exits" do + expect(knife.ui).to receive(:error) + expect{knife.register_client}.to raise_error(SystemExit) + end + end + end + context "when chef_vault_handler says we're using vault" do + let(:vault_handler_mock) { double("ChefVaultHandler") } + before do + allow(vault_handler_mock).to receive(:doing_chef_vault?).and_return true + end + it_behaves_like "creating the client locally" + end + + context "when an non-existant validation key is specified in chef config" do + before do + Chef::Config[:validation_key] = "/blah" + allow(vault_handler_mock).to receive(:doing_chef_vault?).and_return false + allow(File).to receive(:exist?).with("/blah").and_return false + end + it_behaves_like "creating the client locally" + end - it "prompts the user for a password " do - expect(knife_ssh_with_password_auth.config[:ssh_password]).to eq("typed_in_password") + context "when a valid validation key is given and we're doing old-style client creation" do + before do + Chef::Config[:validation_key] = "/blah" + allow(File).to receive(:exist?).with("/blah").and_return true + allow(vault_handler_mock).to receive(:doing_chef_vault?).and_return false end - it "configures knife not to use the identity file that didn't work previously" do - expect(knife_ssh_with_password_auth.config[:ssh_identity_file]).to be_nil + it "shows a message" do + expect(knife.ui).to receive(:info) + knife.register_client end end end - it "verifies that a server to bootstrap was given as a command line arg" do - knife.name_args = nil - expect { knife.run }.to raise_error(SystemExit) - expect(stderr.string).to match(/ERROR:.+FQDN or ip/) + describe "#perform_bootstrap" do + let(:exit_status) { 0 } + let(:result_mock) { double("result", exit_status: exit_status, stderr: "A message") } + + before do + allow(target_host).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). + to receive(:run_command). + with("sh /path.sh"). + and_yield("output here"). + and_return result_mock + + expect(knife.ui).to receive(:msg).with(/testhost/) + knife.perform_bootstrap("/path.sh") + end + context "when the remote command fails" do + let(:exit_status) { 1 } + it "shows an error and exits" 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).to receive(:run_command).with("sh /path.sh").and_return result_mock + expect{knife.perform_bootstrap("/path.sh")}.to raise_error(SystemExit) + end + end end - describe "when running the bootstrap" do - let(:knife_ssh) do - knife.name_args = ["foo.example.com"] - knife.config[:chef_node_name] = "foo.example.com" - knife.config[:ssh_user] = "rooty" - knife.config[:ssh_identity_file] = "~/.ssh/me.rsa" - allow(knife).to receive(:render_template).and_return("") - knife_ssh = knife.knife_ssh - allow(knife).to receive(:knife_ssh).and_return(knife_ssh) - knife_ssh + + describe "#connect!" do + context "in the normal case" do + it "connects using the connection_opts and notifies the operator of progress" do + expect(knife.ui).to receive(:info).with(/Connecting to.*/) + expect(knife).to receive(:connection_opts).and_return( { opts: "here" }) + expect(knife).to receive(:do_connect).with( { opts: "here" } ) + knife.connect! + end + end + + context "when a non-auth-failure occurs" do + let(:expected_error) { RuntimeError.new } + before do + allow(knife).to receive(:do_connect).and_raise(expected_error) + end + it "re-raises the exception" do + expect{knife.connect!}.to raise_error(expected_error) + end end - let(:client) { Chef::ApiClient.new } - context "when running with a configured and present validation key" do + context "when an auth failure occurs" do + let(:expected_error) { + # 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 + actual = Net::SSH::AuthenticationFailed.new + allow(interim).to receive(:cause).and_return(actual) + allow(e).to receive(:cause).and_return(interim) + e + } + before do - # this tests runs the old code path where we have a validation key, so we need to pass that check - allow(File).to receive(:exist?).with(File.expand_path(Chef::Config[:validation_key])).and_return(true) + require 'net/ssh' end - it "configures the underlying ssh command and then runs it" do - expect(knife_ssh).to receive(:run) - knife.run + context "and password auth was used" do + before do + knife.config[:password] = "tryme" + end + + it "re-raises the error so as not to resubmit the same failing password" do + expect(knife).to receive(:do_connect).and_raise(expected_error) + expect{knife.connect!}.to raise_error(expected_error) + end end - it "falls back to password based auth when auth fails the first time" do - allow(knife).to receive(:puts) + context "and password auth was not used" do + before do + knife.config[:password] = nil + allow(target_host).to receive(:user).and_return "testuser" + end - fallback_knife_ssh = knife_ssh.dup - expect(knife_ssh).to receive(:run).and_raise(Net::SSH::AuthenticationFailed.new("no ssh for you")) - allow(knife).to receive(:knife_ssh_with_password_auth).and_return(fallback_knife_ssh) - expect(fallback_knife_ssh).to receive(:run) - knife.run + it "warns, prompts for password, then reconnects with a password-enabled configuration using the new password" do + question_mock = double("question") + expect(knife).to receive(:do_connect).and_raise(expected_error) + expect(knife.ui).to receive(:warn).with(/Failed to auth.*/) + expect(knife.ui).to receive(:ask).and_yield(question_mock).and_return("newpassword") + # Ensure that we set echo off to prevent showing password on the screen + expect(question_mock).to receive(:echo=).with false + expect(knife).to receive(:do_connect) do |opts| + expect(opts[:password]).to eq "newpassword" + end + knife.connect! + end end + end + end + + + + it "verifies that a server to bootstrap was given as a command line arg" do + knife.name_args = nil + expect { knife.run }.to raise_error(SystemExit) + expect(stderr.string).to match(/ERROR:.+FQDN or ip/) + end - it "raises the exception if config[:ssh_password] is set and an authentication exception is raised" do - knife.config[:ssh_password] = "password" - expect(knife_ssh).to receive(:run).and_raise(Net::SSH::AuthenticationFailed) - expect { knife.run }.to raise_error(Net::SSH::AuthenticationFailed) + describe "#bootstrap_context" do + context "under Windows" do + let(:base_os) { :windows } + it "creates a WindowsBootstrapContext" do + require 'chef/knife/core/windows_bootstrap_context' + expect(knife.bootstrap_context.class).to eq Chef::Knife::Core::WindowsBootstrapContext end + end - it "creates the client and adds chef-vault items if vault_list is set" do - knife.config[:bootstrap_vault_file] = "/not/our/responsibility/to/check/if/this/exists" - expect(knife_ssh).to receive(:run) - expect(knife.client_builder).to receive(:run) - expect(knife.client_builder).to receive(:client).and_return(client) - expect(knife.chef_vault_handler).to receive(:run).with(client) - knife.run + context "under linux" do + let(:base_os) { :linux } + it "creates a BootstrapContext" do + require 'chef/knife/core/bootstrap_context' + expect(knife.bootstrap_context.class).to eq Chef::Knife::Core::BootstrapContext end + end + end - it "creates the client and adds chef-vault items if vault_items is set" do - knife.config[:bootstrap_vault_json] = '{ "vault" => "item" }' - expect(knife_ssh).to receive(:run) - expect(knife.client_builder).to receive(:run) - expect(knife.client_builder).to receive(:client).and_return(client) - expect(knife.chef_vault_handler).to receive(:run).with(client) - knife.run + + describe "#config_value" do + before do + knife.config[:test_key_a] = "a from cli" + knife.config[:test_key_b] = "b from cli" + Chef::Config[:knife][:test_key_a] = "a from Chef::Config" + Chef::Config[:knife][:test_key_c] = "c from Chef::Config" + Chef::Config[:knife][:alt_test_key_c] = "alt c from Chef::Config" + end + + it "returns CLI value when key is only provided by the CLI" do + expect(knife.config_value(:test_key_b)).to eq "b from cli" + end + + it "returns CLI value when key is provided by CLI and Chef::Config" do + expect(knife.config_value(:test_key_a)).to eq "a from cli" + end + + it "returns Chef::Config value whent he key is only provided by Chef::Config" do + expect(knife.config_value(:test_key_c)).to eq "c from Chef::Config" + end + + it "returns the Chef::Config value from the alternative key when the CLI key is not set" do + expect(knife.config_value(:test_key_c, :alt_test_key_c)).to eq "alt c from Chef::Config" + end + + it "returns the default value when the key is not provided by CLI or Chef::Config" do + expect(knife.config_value(:missing_key, :missing_key, "found")).to eq "found" + end + end + + 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 } + end + + let(:content) { "bootstrap script content" } + context "under Windows" do + let(:base_os) { :windows } + 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") + expect(knife.upload_bootstrap(content)).to eq "C:/Temp/bootstrap/bootstrap.bat" end + end - it "does old-style validation without creating a client key if vault_list+vault_items is not set" do - expect(File).to receive(:exist?).with(File.expand_path(Chef::Config[:validation_key])).and_return(true) - expect(knife_ssh).to receive(:run) - expect(knife.client_builder).not_to receive(:run) - expect(knife.chef_vault_handler).not_to receive(:run) - knife.run + context "under Linux" do + let(:base_os) { :linux } + 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") + expect(knife.upload_bootstrap(content)).to eq "/tmp/bootstrap/bootstrap.sh" end + end + end - it "raises an exception if the config[:chef_node_name] is not present" do - knife.config[:chef_node_name] = nil + describe "#bootstrap_command" do + context "under Windows" do + let(:base_os) { :windows } + it "prefixes the command to run under cmd.exe" do + expect(knife.bootstrap_command("autoexec.bat")).to eq "cmd.exe /C autoexec.bat" + end - expect { knife.run }.to raise_error(SystemExit) + end + context "under Linux" do + let(:base_os) { :linux } + it "prefixes the command to run under sh" do + expect(knife.bootstrap_command("bootstrap")).to eq "sh bootstrap" end end + end - context "when the validation key is not present" do - before do - # this tests runs the old code path where we have a validation key, so we need to pass that check - allow(File).to receive(:exist?).with(File.expand_path(Chef::Config[:validation_key])).and_return(false) + + describe "#default_bootstrap_template" do + context "under Windows" do + let(:base_os) { :windows } + it "is windows-chef-client-msi" do + expect(knife.default_bootstrap_template).to eq "windows-chef-client-msi" end - it "creates the client (and possibly adds chef-vault items)" do - expect(knife_ssh).to receive(:run) - expect(knife.client_builder).to receive(:run) - expect(knife.client_builder).to receive(:client).and_return(client) - expect(knife.chef_vault_handler).to receive(:run).with(client) - knife.run + end + context "under Linux" do + let(:base_os) { :linux } + it "is chef-full" do + expect(knife.default_bootstrap_template).to eq "chef-full" end + end + end + + describe "#do_connect" do + let(:host_descriptor) { "example.com" } + let(:target_host) { double("TargetHost") } + let(:resolver_mock) { double("TargetResolver", targets: [ target_host ]) } + before do + allow(knife).to receive(:host_descriptor).and_return host_descriptor + end - it "raises an exception if the config[:chef_node_name] is not present" do - knife.config[:chef_node_name] = nil + it "resolves the target and connects it" do + expect(ChefCore::TargetResolver).to receive(:new).and_return resolver_mock + expect(target_host).to receive(:connect!) + knife.do_connect({}) + end + end - expect { knife.run }.to raise_error(SystemExit) + describe "validate_winrm_transport_opts!" do + before do + allow(knife).to receive(:connection_protocol).and_return connection_protocol + end + + context "when using ssh" do + let(:connection_protocol) { "ssh" } + it "returns true" do + expect(knife.validate_winrm_transport_opts!).to eq true end end + context "when using winrm" do + let(:connection_protocol) { "winrm" } + context "with plaintext auth" do + before do + knife.config[:winrm_auth_method] = "plaintext" + end + context "with ssl" do + before do + knife.config[:winrm_ssl] = true + end + it "will not error because we won't send anything in plaintext regardless" do + expect(knife.validate_winrm_transport_opts!).to eq true + end + end + context "without ssl" do + before do + knife.config[:winrm_ssl] = false + end + context "and no validation key exists" do + before do + Chef::Config[:validation_key] = "validation_key.pem" + allow(File).to receive(:exist?).with(/.*validation_key.pem/).and_return false + end - context "when the validation_key is nil" do - before do - # this tests runs the old code path where we have a validation key, so we need to pass that check for some plugins - Chef::Config[:validation_key] = nil + it "will error because we will generate and send a client key over the wire in plaintext" do + expect{knife.validate_winrm_transport_opts!}.to raise_error(SystemExit) + end + + end + context "and a validation key exists" do + before do + Chef::Config[:validation_key] = "validation_key.pem" + allow(File).to receive(:exist?).with(/.*validation_key.pem/).and_return true + end + # TODO - don't we still send validation key? + it "will not error because we don not send client key over the wire" do + expect(knife.validate_winrm_transport_opts!).to eq true + end + end + end end - it "creates the client and does not run client_builder or the chef_vault_handler" do - expect(knife_ssh).to receive(:run) - expect(knife.client_builder).not_to receive(:run) - expect(knife.chef_vault_handler).not_to receive(:run) - knife.run + context "with other auth" do + before do + knife.config[:winrm_auth_method] = "kerberos" + end + + context "and no validation key exists" do + before do + + Chef::Config[:validation_key] = "validation_key.pem" + allow(File).to receive(:exist?).with(/.*validation_key.pem/).and_return false + end + + it "will not error because we're not using plaintext auth" do + expect(knife.validate_winrm_transport_opts!).to eq true + end + end + context "and a validation key exists" do + before do + Chef::Config[:validation_key] = "validation_key.pem" + allow(File).to receive(:exist?).with(/.*validation_key.pem/).and_return true + end + + it "will not error because a client key won't be sent over the wire in plaintext when a validation key is present" do + expect(knife.validate_winrm_transport_opts!).to eq true + end + end + end + end + end - describe "specifying ssl verification" do + describe "#winrm_warn_no_ssl_verification" do + before do + allow(knife).to receive(:connection_protocol).and_return connection_protocol + end + + context "when using ssh" do + let(:connection_protocol) { "ssh" } + it "does not issue a warning" do + expect(knife.ui).to_not receive(:warn) + knife.winrm_warn_no_ssl_verification + end + end + context "when using winrm" do + let(:connection_protocol) { "winrm" } + context "winrm_no_verify_cert is set" do + before do + knife.config[:winrm_no_verify_cert] = true + end + + context "and ca_trust_file is present" do + before do + knife.config[:ca_trust_file] = "file" + end + it "does not issue a warning" do + expect(knife.ui).to_not receive(:warn) + knife.winrm_warn_no_ssl_verification + end + end + + context "and winrm_ssl_peer_fingerprint is present" do + before do + knife.config[:winrm_ssl_peer_fingerprint] = "ABCD" + end + it "does not issue a warning" do + expect(knife.ui).to_not receive(:warn) + knife.winrm_warn_no_ssl_verification + end + end + context "and neither ca_trust_file nor winrm_ssl_peer_fingerprint is present" do + it "issues a warning" do + expect(knife.ui).to receive(:warn) + knife.winrm_warn_no_ssl_verification + end + end + end + end end end + + |