diff options
author | Tim Smith <tsmith@chef.io> | 2019-04-24 10:49:33 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-04-24 10:49:33 -0700 |
commit | f99ecb0fe7d1be9d01ffc575590ec18014ca2f4b (patch) | |
tree | 861bd855664dff59cc9a1ad41b40da431af668b7 | |
parent | f492fe53eac1b74a0d184f0e9cf7412b70770e29 (diff) | |
parent | 8770eef5239bace3e2919accdc3cc672867c76a7 (diff) | |
download | chef-f99ecb0fe7d1be9d01ffc575590ec18014ca2f4b.tar.gz |
Merge pull request #8253 from chef/SUSTAINING-955/bootstrap-on-chef-core
Refactor bootstrapping to use train as the transport with full Windows bootstrap support
21 files changed, 3296 insertions, 409 deletions
diff --git a/Gemfile.lock b/Gemfile.lock index 13be39ec98..526d7271c5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -32,6 +32,7 @@ PATH bcrypt_pbkdf (~> 1.0) bundler (>= 1.10) chef-config (= 15.0.232) + chef-core (~> 0.0.3) chef-zero (>= 14.0.11) diff-lcs (~> 1.2, >= 1.2.4) ed25519 (~> 1.2) @@ -60,6 +61,7 @@ PATH bcrypt_pbkdf (~> 1.0) bundler (>= 1.10) chef-config (= 15.0.232) + chef-core (~> 0.0.3) chef-zero (>= 14.0.11) diff-lcs (~> 1.2, >= 1.2.4) ed25519 (~> 1.2) @@ -111,7 +113,7 @@ GEM specs: addressable (2.5.2) public_suffix (>= 2.0.2, < 4.0) - appbundler (0.12.3) + appbundler (0.12.4) mixlib-cli (>= 1.4, < 3.0) mixlib-shellout (>= 2.0, < 4.0) ast (2.4.0) @@ -122,6 +124,20 @@ GEM debug_inspector (>= 0.0.1) builder (3.2.3) byebug (11.0.1) + chef-core (0.0.3) + chef-telemetry + mixlib-log + pastel + r18n-desktop + train-core (~> 2.0, >= 2.0.12) + tty-color + tty-cursor + tty-spinner + chef-telemetry (0.1.8) + chef-config + concurrent-ruby (~> 1.0) + ffi-yajl (~> 2.2) + http (~> 2.2) chef-vault (3.4.3) chef-zero (14.0.12) ffi-yajl (~> 2.2) @@ -133,11 +149,14 @@ GEM chef-zero (~> 14.0) net-ssh coderay (1.1.2) + concurrent-ruby (1.1.5) crack (0.4.3) safe_yaml (~> 1.0.0) debug_inspector (0.0.3) diff-lcs (1.3) docile (1.3.1) + domain_name (0.5.20180417) + unf (>= 0.0.5, < 1.0.0) ed25519 (1.2.4) equatable (0.5.0) erubis (2.7.0) @@ -163,6 +182,15 @@ GEM hashie (3.6.0) highline (1.7.10) htmlentities (4.3.4) + http (2.2.2) + addressable (~> 2.3) + http-cookie (~> 1.0) + http-form_data (~> 1.0.1) + http_parser.rb (~> 0.6.0) + http-cookie (1.0.3) + domain_name (~> 0.5) + http-form_data (1.0.3) + http_parser.rb (0.6.0) httpclient (2.8.3) iniparse (1.4.4) inspec-core (4.1.4.preview) @@ -258,6 +286,9 @@ GEM binding_of_caller (>= 0.7) pry (>= 0.9.11) public_suffix (3.0.3) + r18n-core (3.2.0) + r18n-desktop (3.2.0) + r18n-core (= 3.2.0) rack (2.0.7) rainbow (3.0.0) rake (12.3.2) @@ -344,12 +375,17 @@ GEM tty-screen (~> 0.6.4) wisper (~> 2.0.0) tty-screen (0.6.5) + tty-spinner (0.9.0) + tty-cursor (~> 0.6.0) tty-table (0.10.0) equatable (~> 0.5.0) necromancer (~> 0.4.0) pastel (~> 0.7.2) strings (~> 0.1.0) tty-screen (~> 0.6.4) + unf (0.1.4) + unf_ext + unf_ext (0.0.7.6) unicode-display_width (1.4.1) unicode_utils (1.4.0) uuidtools (2.1.5) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 58018ee63f..ef2e843bd5 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -44,6 +44,74 @@ The LC_ALL property in the locale resource has been deprecated as the usage of t ## Breaking Changes +### Knife Bootstrap + +Knife bootstrap has been updated, and Windows bootstrap has been merged into core Chef's `knife bootstrap`. This marks the deprecation of the `knife-windows` plugin's `bootstrap` behavior. +This change also addresses [CVE-2015-8559](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2015-8559): The `knife bootstrap` command in chef leaks the validator.pem private RSA key to /var/log/messages. + +*Important*: `knife bootstrap` works with all supported versions of Chef client. Older versions may continue to work as far back as 12.20 + +In order to accomodate a combined bootstrap that supports both SSH and WinRM, +CLI flags have been added, removed, or changed. Using the changed options will +result in deprecation warnings, but `knife bootstrap` will accept those options +unless otherwise noted. + +Using removed options will cause the command to fail. + +#### New Flags + +| Flag | Description | +|-----:|:------------| +| --max-wait SECONDS | Maximum time to wait for initial connection to be established. | +| --winrm-basic-auth-only | Perform only Basic Authentication to the target WinRM node. | +| --connection-protocol PROTOCOL|Connection protocol to use. Valid values are 'winrm' and 'ssh'. Default is 'ssh'. | +| --connection-user | user to authenticate as, regardless of protocol | +| --connection-password| Password to authenticate as, regardless of protocol | +| --connection-port | port to connect to, regardless of protocol | + +#### Changed Flags + +| Flag | New Option | Notes | +|-----:|:-----------|:------| +| --[no-]host-key-verify |--[no-]ssh-verify-host-key| | +| --forward-agent | --ssh-forward-agent| | +| --session-timeout MINUTES | --session-timeout SECONDS|New for ssh, existing for winrm. The unit has changed from MINUTES to SECONDS for consistency with other timeouts.| +| --ssh-password | --connection-password | | +| --ssh-port | --connection-port | `knife[:ssh_port]` config setting remains available. +| --ssh-user | --connection-user | `knife[:ssh_user]` config setting remains available. +| --ssl-peer-fingerprint | --winrm-ssl-peer-fingerprint | | +| --winrm-authentication-protocol=PROTO | --winrm-auth-method=AUTH-METHOD | Valid values: plaintext, kerberos, ssl, _negotiate_| +| --winrm-password| --connection-password | | +| --winrm-port| --connection-port | `knife[:winrm_port]` config setting remains available.| +| --winrm-ssl-verify-mode MODE | --winrm-no-verify-cert | [1] Mode is not accepted. When flag is present, SSL cert will not be verified. Same as original mode of 'verify_none'. | +| --winrm-transport TRANSPORT | --winrm-ssl | [1] Use this flag if the target host is accepts WinRM connections over SSL. +| --winrm-user | --connection-user | `knife[:winrm_user]` config setting remains available.| + +1. These flags do not have an automatic mapping of old flag -> new flag. The + new flag must be used. + +#### Removed Flags + +| Flag | Notes | +|-----:|:------| +|--kerberos-keytab-file| This option existed but was not implemented.| +|--winrm-codepage| This was used under knife-windows because bootstrapping was performed over a `cmd` shell. It is now invoked from `powershell`, so this option is no longer used.| +|--winrm-shell|This option was ignored for bootstrap.| +|--prerelease|Chef now releases all development builds to our current channel and does not perform pre-release gem releases.| +|--install-as-service|Installing Chef client as a service is not supported| + +#### Usage Changes + +Instead of specifying protocol with `-o`, it's also possible to prefix +the target hostname with the protocol in URL format. For example: + +``` + knife bootstrap example.com -o ssh + knife bootstrap ssh://example.com + knife bootstrap example.com -o winrm + knife bootstrap winrm://example.com +``` + ### Audit Mode Chef's Audit mode was introduced in 2015 as a beta that needed to be enabled via client.rb. Its functionality has been superceded by InSpec and has been removed. diff --git a/chef.gemspec b/chef.gemspec index 63f9a8c331..32e12bad41 100644 --- a/chef.gemspec +++ b/chef.gemspec @@ -16,6 +16,7 @@ Gem::Specification.new do |s| s.required_ruby_version = ">= 2.5.0" s.add_dependency "chef-config", "= #{Chef::VERSION}" + s.add_dependency "chef-core", "~> 0.0.3" s.add_dependency "mixlib-cli", ">= 1.7", "< 3.0" s.add_dependency "mixlib-log", ">= 2.0.3", "< 4.0" diff --git a/docs/dev/design_documents/bootstrap_with_train.md b/docs/dev/design_documents/bootstrap_with_train.md new file mode 100644 index 0000000000..be4fdeb3ec --- /dev/null +++ b/docs/dev/design_documents/bootstrap_with_train.md @@ -0,0 +1,150 @@ +# Bootstrap with Train + +Update `knife bootstrap` to use `train` as its backend via `chef-core`, and integrate Windows bootstrap support. + +## Motivation + + As a Chef User, + I want to be able to bootstrap a system without logging secure data on that system + so that chef-client's keys are not exposed to anyone who can read the logs. + + As a Chef User who adminsters Windows nodes, + I want to be able to bootstrap a system using the core Chef package + so that I don't have extra things to download first. + + As a Chef Developer who works on bootstrap, + I want to be able to maintain one copy of the bootstrap logic + so that I don't have to spend time keeping a second copy in sync. + +## Summary + +The Windows bootstrap process has lived outside of core Chef for a long time. +Switching to Train as the supporting back-end gives us the opportunity to merge +the `knife-windows` bootstrap behavior into core knife. This will reduce the maintenance burden +of maintaining what is a mostly-complete copy of bootstrap behaviors in `knife-windows`. + +This also addresses [CVE-2015-8559](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2015-8559), in which +the bootstrap mechanism runs the full bootstrap script as an inline argument to bash/cmd.exe, resulting +in sensitive data potentially getting logged on the remote system. Train provides a back-end that knows how to +do file management and command execution over supported protocols. This allows us to upload the +bootstrap script and execute it in a remote shell without exposing the contents in a way that could result +in capturing them in system logs. + +## Anatomy of a Bootstrap + +Bootstrap follows this general flow: + +* validate CLI options are proper +* register the new client if not using org validation key to create it +* determine connection properties based on protocol +* connect to the remote host +* generate and upload the bootstrap script +* remotely run the bootstrap script + +This change focuses on configuring the connection +and executing the bootstrap script. The underlying bootstrap behavior itself +remains largely unchanged. + +## Implementation + +### Remove Unsupported Behaviors + +We will also remove the following obsolete or unsupported behaviors: + +* `--prelease` flag - Chef hasn't been pre-released in quite some time. +* `--install-as-service` - For many years we have suggested users not run chef-client as a service due to memory leaks in long running Ruby processes. +* `--kerberos-keytab-file` - this is not implemented in the WinRM gem we use, and so was +passed through to no effect. +* remove explicit support for versions of Chef older than 12.8. Versions older than the supported + Chef client distributions will continue to be use at your own risk. +* Remove support for Windows 2003 in the Windows bootstrap template as Chef does not support EOL Windows 2003 installs. + +### CLI Flag Changes + +As part of this change, CLI options from `knife bootstrap windows winrm` and `knife bootstrap` +need to be merged. The majority will be untouched, but we'll also take this opportunity +to make flag names more accurately describe what they're doing, and updating several options that are +protocol-specific to be prefixed with the protocol (e.g. `--ssl-peer-fingerprint` to `--winrm-ssl-peer-fingerprint`) +When a direct mapping exists, the original names will continue to work with backward +compatibility and a deprecation warning if they have changed. + +#### New CLI Flags + +| Flag | Description | +|-----:|:------------| +| --max-wait SECONDS | Maximum time to wait for initial connection to be established. | +| --winrm-basic-auth-only | Perform only Basic Authentication to the target WinRM node. | +| --connection-protocol PROTOCOL|Connection protocol to use. Valid values are 'winrm' and 'ssh'. Default is 'ssh'. | +| --connection-user | user to authenticate as, regardless of protocol | +| --connection-password| Password to authenticate as, regardless of protocol | +| --connection-port | port to connect to, regardless of protocol | + +`--connection-user`, `--connection-port`, and `--connection-password` replace their protocol-specific counterparts, since +these are applicable to all supported transports. Their original knife config keys (`ssh\_user`, `ssh\_password`, etc.) remain +available for use. + +Note that auth-related configuration may see further changes as work proceeds on credential set support for train. + +### Changed CLI Flags + +| Flag | New Option | Notes | +|-----:|:-----------|:------| +| --[no-]host-key-verify |--[no-]ssh-verify-host-key| | +| --forward-agent | --ssh-forward-agent| | +| --session-timeout MINUTES | --session-timeout SECONDS|New for ssh, existing for winrm. The unit has changed from MINUTES to SECONDS for consistency with other timeouts.| +| --ssh-password | --connection-password | | +| --ssh-port | --connection-port | `knife[:ssh_port]` config setting remains available. +| --ssh-user | --connection-user | `knife[:ssh_user]` config setting remains available. +| --ssl-peer-fingerprint | --winrm-ssl-peer-fingerprint | | +| --winrm-authentication-protocol=PROTO | --winrm-auth-method=AUTH-METHOD | Valid values: plaintext, kerberos, ssl, _negotiate_| +| --winrm-password| --connection-password | | +| --winrm-port| --connection-port | `knife[:winrm_port]` config setting remains available.| +| --winrm-ssl-verify-mode MODE | --winrm-no-verify-cert | [1] Mode is not accepted. When flag is present, SSL cert will not be verified. Same as original mode of 'verify_none'. | +| --winrm-transport TRANSPORT | --winrm-ssl | [1] Use this flag if the target host is accepts WinRM connections over SSL. +| --winrm-user | --connection-user | `knife[:winrm_user]` config setting remains available.| + +1. These flags do not have an automatic mapping of old flag -> new flag. The + new flag must be used. + +### Removed Flags + +| Flag | Notes | +|-----:|:------| +|--kerberos-keytab-file| This option existed but was not implemented.| +|--winrm-codepage| This was used under `knife-windows` because bootstrapping was performed over a `cmd` shell. It is now invoked from `powershell`, so this option is no longer required.| +|--winrm-shell| This option was ignored for bootstrap.| +|--prerelease|Prerelease Chef hasn't existed for some time.| +|--install-as-service|Installing Chef client as a service is not supported| + +### Conversion to ChefCore and Train + +CLI and knife options will be mapped to their train counterparts, and passed through to `TargetHost` to establish a connection. +The TargetHost instance will be used for all upload and execution operations. + +Tests must ensure that options resolve correctly from the CLI, knife configuration, and defaults; and that they map to the corresponding +`train` options. + +#### Validation + +Existing windows bootstrap validation checks should be preserved, unless they are superceded by related +validations for ssh bootstrap. + +#### Context + +`WindowsBootstrapContext` will be moved into knife, with updates for namespacing as needed. + +#### Template + +`knife-windows/lib/chef/knife/bootstrap/templates/windows-chef-client-msi.erb` will be moved into +knife's bootstrap templates. + +### Future Improvements + +Because there are only two supported protocols for the near-term future, +it does not add much benefit to split out the bootstrap CLI behavior based on +protocol, so both are handled within the bootstrap command directly. + +If we want to support additional protocols, it will become unwieldy to continue with protocol `if` +checks, and would be advisable to separate out protocol-specific behaviors +into classes determined at runtime based on protocol. + diff --git a/lib/chef.rb b/lib/chef.rb index 3d6b783253..c58f46debd 100644 --- a/lib/chef.rb +++ b/lib/chef.rb @@ -17,6 +17,19 @@ # require "chef/version" + +# Ensure that this loads ahead of anything that +# might cause rubygems to hit Gem.load_yaml, including +# evaluating gemspecs. When load_yaml is invoked, +# it stubs out the YAML::Syck namespace. This causes +# r18n to break, which expects either YAML::Syck to be there +# and fully defined (particularly, the Syck::PrivateType class), +# or for it to not be there at all. +# +# When it's not - because it's a stub - r18n explodes on loading. +# Ensuring chef_core/text and r18n are loaded first prevents this. +require "chef_core/text" + require "chef/nil_argument" require "chef/mash" require "chef/exceptions" diff --git a/lib/chef/knife.rb b/lib/chef/knife.rb index b5b32cb193..aac0c8e258 100644 --- a/lib/chef/knife.rb +++ b/lib/chef/knife.rb @@ -344,8 +344,15 @@ class Chef # Chef::Config[:knife] would break the defaults in the cli that we would otherwise # overwrite. def config_file_settings + @key_sources = { cli: [], config: [] } cli_keys.each_with_object({}) do |key, memo| - memo[key] = Chef::Config[:knife][key] if Chef::Config[:knife].key?(key) + if config.key?(key) + @key_sources[:cli] << key + end + if Chef::Config[:knife].key?(key) + @key_sources[:config] << key + memo[key] = Chef::Config[:knife][key] + end end end @@ -356,9 +363,15 @@ class Chef def merge_configs # other code may have a handle to the config object, so use Hash#replace to deliberately # update-in-place. - config.replace( - default_config.merge(config_file_settings).merge(config) - ) + config.replace(default_config.merge(config_file_settings).merge(config)) + end + + # Return where a config key has been sourced, + # :cli, :config, or nil if the key is not set. + def config_source(key) + return :cli if @key_sources[:cli].include? key + return :config if @key_sources[:config].include? key + nil end # Catch-all method that does any massaging needed for various config diff --git a/lib/chef/knife/bootstrap.rb b/lib/chef/knife/bootstrap.rb index b9e09a15ba..b68fb1aa1e 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"); @@ -28,39 +28,91 @@ class Chef class Bootstrap < Knife include DataBagSecretOptions - attr_accessor :client_builder - attr_accessor :chef_vault_handler - - 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)" + SUPPORTED_CONNECTION_PROTOCOLS = %w{ssh winrm}.freeze + WINRM_AUTH_PROTOCOL_LIST = %w{plaintext kerberos ssl negotiate}.freeze - option :ssh_user, - short: "-x USERNAME", - long: "--ssh-user USERNAME", - description: "The ssh username", - default: "root" + # Common connectivity options + option :connection_user, + short: "-U USERNAME", + long: "--connection-user USERNAME", + description: "Authenticate to the target host with this user account" - option :ssh_password, + option :connection_password, short: "-P PASSWORD", - long: "--ssh-password PASSWORD", - description: "The ssh password" + long: "--connection-password PASSWORD", + description: "Authenticate to the target host with this password" - option :ssh_port, + option :connection_port, short: "-p PORT", - long: "--ssh-port PORT", - description: "The ssh port", - proc: Proc.new { |key| Chef::Config[:knife][:ssh_port] = key } + 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: #{SUPPORTED_CONNECTION_PROTOCOLS.join(" ")}" + + option :max_wait, + short: "-W SECONDS", + long: "--max-wait SECONDS", + description: "The maximum time to wait for the initial connection to be established." + + # WinRM Authentication + 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.", + boolean: true + + 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 } + + ## SSH Authentication option :ssh_gateway, short: "-G GATEWAY", long: "--ssh-gateway GATEWAY", @@ -72,9 +124,9 @@ class Chef description: "The SSH identity file used for gateway authentication", proc: Proc.new { |key| Chef::Config[:knife][:ssh_gateway_identity] = key } - option :forward_agent, + option :ssh_forward_agent, short: "-A", - long: "--forward-agent", + long: "--ssh-forward-agent", description: "Enable SSH agent forwarding", boolean: true @@ -83,58 +135,92 @@ class Chef 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 :ssh_verify_host_key, + long: "--[no-]ssh-verify-host-key", + description: "Verify host key, enabled by default.", + boolean: true - option :prerelease, - long: "--prerelease", - description: "Install the pre-release chef gems" + # + # bootstrap options + # + # 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 Opscode", + 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", @@ -142,22 +228,26 @@ class Chef 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", @@ -165,18 +255,21 @@ class Chef 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 - option :host_key_verify, - long: "--[no-]host-key-verify", - description: "Verify host key, enabled by default.", - boolean: true, - default: true + # 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. We cannot always used the merged + # config, because in some cases the knife keys thIn 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.", @@ -186,55 +279,55 @@ class Chef 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 } + option :msi_url, # Windows target only + 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: "" + + # 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 } + 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 } - 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 - } - - 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 - + # 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"', @@ -246,6 +339,65 @@ class Chef Chef::Config[:knife][:bootstrap_vault_item] } + # OPTIONAL: This can be exposed as an class method on Knife + # subclasses instead - that would let us move deprecation handling + # up into the base clase. + DEPRECATED_FLAGS = { + # old_key: [:new_key, old_long, new_long] + auth_timeout: [:max_wait, "--max-wait SECONDS"], + host_key_verify: [:ssh_verify_host_key, + "--[no-]host-key-verify", + ], + ssh_user: [:connection_user, + "--ssh-user USER", + ], + ssh_password: [:connection_password, + "--ssh-password PASSWORD", + ], + ssh_port: [:connection_port, + "-ssh-port", + ], + ssl_peer_fingerprint: [:winrm_ssl_peer_fingerprint, + "--ssl-peer-fingerprint FINGERPRINT", + ], + winrm_user: [:connection_user, + "--winrm-user USER", + ], + winrm_password: [:connection_password, + "--winrm-password", + ], + winrm_port: [:connection_port, + "--winrm-port", + ], + winrm_authentication_protocol: [:winrm_auth_method, + "--winrm-authentication-protocol PROTOCOL", + ], + }.freeze + + DEPRECATED_FLAGS.each do |flag, new_flag_config| + new_flag, old_long = new_flag_config + new_long = options[new_flag][:long] + new_flag_name = new_long.split(" ").first + + option(flag, long: new_long, + description: "#{old_long} is deprecated. Use #{new_long} instead.", + boolean: options[new_flag][:boolean]) + end + + attr_accessor :client_builder + attr_accessor :chef_vault_handler + attr_reader :target_host + + deps do + require "chef/json_compat" + require "tempfile" + require "chef_core/text" # i18n and standardized error structures + require "chef_core/target_host" + require "chef_core/target_resolver" + end + + banner "knife bootstrap [PROTOCOL://][USER@]FQDN (options)" + def initialize(argv = []) super @client_builder = Chef::Knife::Bootstrap::ClientBuilder.new( @@ -259,12 +411,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 +438,9 @@ 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 +480,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 @@ -350,49 +505,181 @@ class Chef Erubis::Eruby.new(template).evaluate(bootstrap_context) end - def run - if @config[:first_boot_attributes] && @config[:first_boot_attributes_from_file] - raise Chef::Exceptions::BootstrapCommandInputError + # Check deprecated flags are used; map them to their new keys, + # and print a warning. Will not map a value to a new key if the + # CLI flag for that new key has also been specified. + # If both old and new flags are specified, this will warn + # and take the new flag value. + # This can be moved up to the base knife class if it's agreeable. + def warn_and_map_deprecated_flags + DEPRECATED_FLAGS.each do |old_key, new_flag_config| + new_key, = new_flag_config + if config.key?(old_key) && config_source(old_key) == :cli + # TODO - do we want the same warnings for knife config keys + # in absence of CLI keys? + if config.key?(new_key) && config_source(new_key) == :cli + new_key_name = "--#{new_key.to_s.tr("_", "-")}" + old_key_name = "--#{old_key.to_s.tr("_", "-")}" + ui.warn <<~EOM + You provided both #{new_key_name} and #{old_key_name}. + Using: '#{new_key_name.split(" ").first} #{config[new_key]}' because #{old_key_name} is deprecated. + EOM + else + config[new_key] = config[old_key] + unless Chef::Config[:silence_deprecation_warnings] == true + ui.warn options[old_key][:description] + end + end + end end + end + + def run + warn_and_map_deprecated_flags 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)}") + 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 - begin - knife_ssh.run - rescue Net::SSH::AuthenticationFailed - if config[:ssh_password] + 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 +687,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 +697,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 +705,284 @@ 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 work around this issue you can use the flag `--winrm-no-verify-cert` + or add an entry like this to your knife configuration file: + + # Verify all WinRM HTTPS connections + knife[:winrm_no_verify_cert] = true + + You can also specify a ca_trust_file via --ca-trust-file, + or the expected fingerprint of the target host's certificate + 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[:connection_password] if config.key?(:connection_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" + # Fall back to the old knife config key name for back compat. + { verify_host_key: config_value(:ssh_verify_host_key, + :host_key_verify, true) === true } + 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?(:connection_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[:connection_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 + + 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 - command + # 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_sym end private @@ -487,7 +1004,6 @@ class Chef def incomplete_policyfile_options? (!!config[:policy_name] ^ config[:policy_group]) end - end end end diff --git a/lib/chef/knife/bootstrap/chef_vault_handler.rb b/lib/chef/knife/bootstrap/chef_vault_handler.rb index 24ed0eb379..233350de73 100644 --- a/lib/chef/knife/bootstrap/chef_vault_handler.rb +++ b/lib/chef/knife/bootstrap/chef_vault_handler.rb @@ -15,7 +15,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -require "chef/knife/bootstrap" class Chef class Knife diff --git a/lib/chef/knife/bootstrap/client_builder.rb b/lib/chef/knife/bootstrap/client_builder.rb index 5fb0edc31b..09f4f67ced 100644 --- a/lib/chef/knife/bootstrap/client_builder.rb +++ b/lib/chef/knife/bootstrap/client_builder.rb @@ -20,7 +20,6 @@ require "chef/node" require "chef/server_api" require "chef/api_client/registration" require "chef/api_client" -require "chef/knife/bootstrap" require "tmpdir" class Chef 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..37fcf15682 --- /dev/null +++ b/lib/chef/knife/bootstrap/templates/windows-chef-client-msi.erb @@ -0,0 +1,267 @@ +@rem +@rem Author:: Seth Chisamore (<schisamo@chef.io>) +@rem Copyright:: Copyright (c) 2011-2019 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 + +: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 2019 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..355978c79d 100644 --- a/lib/chef/knife/core/bootstrap_context.rb +++ b/lib/chef/knife/core/bootstrap_context.rb @@ -161,14 +161,7 @@ class Chef end if Chef::Config[:fips] - client_rb << <<-CONFIG.gsub(/^ {14}/, "") - fips true - require "chef/version" - 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 + client_rb << "fips true\n" end client_rb @@ -194,28 +187,15 @@ class Chef # # chef version string to fetch the latest current version from omnitruck - # If user is on X.Y.Z bootstrap will use the latest X release - # X here can be 10 or 11 + # If user is on X.Y.Z, bootstrap will use the latest X release def latest_current_chef_version_string - installer_version_string = nil - if @config[:prerelease] - installer_version_string = ["-p"] - 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 -p to the installer string - if chef_version_string.split(".").length > 3 - installer_version_string << "-p" - end - end + chef_version_string = if knife_config[:bootstrap_version] + knife_config[:bootstrap_version] + else + Chef::VERSION.split(".").first + end - installer_version_string.join(" ") + "-v #{chef_version_string}" end def first_boot diff --git a/lib/chef/knife/core/ui.rb b/lib/chef/knife/core/ui.rb index 3f0a697107..9801b8d033 100644 --- a/lib/chef/knife/core/ui.rb +++ b/lib/chef/knife/core/ui.rb @@ -76,7 +76,23 @@ class Chef # # @param message [String] the text string def log(message) - stderr.puts message + lines = message.split("\n") + first_line = lines.shift + stderr.puts first_line + # If the message is multiple lines, + # indent subsequent lines to align with the + # log type prefix ("ERROR: ", etc) + unless lines.empty? + prefix, = first_line.split(":", 2) + return if prefix.nil? + prefix_len = prefix.length + prefix_len -= 9 if color? # prefix includes 9 bytes of color escape sequences + prefix_len += 2 # include room to align to the ": " following PREFIX + padding = " " * prefix_len + lines.each do |line| + stderr.puts "#{padding}#{line}" + end + end rescue Errno::EPIPE => e raise e if @config[:verbosity] >= 2 exit 0 @@ -85,6 +101,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..c7b8666ed0 --- /dev/null +++ b/lib/chef/knife/core/windows_bootstrap_context.rb @@ -0,0 +1,383 @@ +# +# 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 + super(config, run_list, chef_config, secret) + 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.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.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 << "fips true\n" + 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 + chef_version_string = if knife_config[:bootstrap_version] + knife_config[:bootstrap_version] + else + Chef::VERSION.split(".").first + end + + "&v=#{chef_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_command('"') + "\n" + fallback_install_task_command + end + + def bootstrap_directory + "C:\\chef" + end + + def 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) + "msiexec /qn /log #{executor_quote}%CHEF_CLIENT_MSI_LOG_PATH%#{executor_quote} /i #{executor_quote}%LOCAL_DESTINATION_MSI_PATH%#{executor_quote}" + 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}".tr("/", "\\") + 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/data/trusted_certs_empty/.gitkeep b/spec/data/trusted_certs_empty/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/spec/data/trusted_certs_empty/.gitkeep diff --git a/spec/data/trusted_certs_empty/README.md b/spec/data/trusted_certs_empty/README.md new file mode 100644 index 0000000000..e7e52627f1 --- /dev/null +++ b/spec/data/trusted_certs_empty/README.md @@ -0,0 +1 @@ +A directory with no certs. Used for testing directories with no certs during bootstrap. diff --git a/spec/integration/knife/upload_spec.rb b/spec/integration/knife/upload_spec.rb index 3f42b08f21..766f91ab25 100644 --- a/spec/integration/knife/upload_spec.rb +++ b/spec/integration/knife/upload_spec.rb @@ -641,8 +641,8 @@ EOM (right here) ------^ ERROR: /environments/x.json failed to write: Parse error reading JSON: parse error: premature EOF - { - (right here) ------^ + { + (right here) ------^ EOH warn = <<~EOH diff --git a/spec/support/shared/integration/integration_helper.rb b/spec/support/shared/integration/integration_helper.rb index 5fc9de4de7..b6851f2d0e 100644 --- a/spec/support/shared/integration/integration_helper.rb +++ b/spec/support/shared/integration/integration_helper.rb @@ -19,6 +19,7 @@ require "tmpdir" require "fileutils" +require "chef_core/text" require "chef/config" require "chef/json_compat" require "chef/server_api" diff --git a/spec/unit/knife/bootstrap_spec.rb b/spec/unit/knife/bootstrap_spec.rb index 258d193cf1..f54c8ac1d6 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 @@ -441,11 +447,13 @@ describe Chef::Knife::Bootstrap do end end - it "doesn't create /etc/chef/trusted_certs if :trusted_certs_dir is empty" do - allow(Dir).to receive(:glob).and_call_original - expect(Dir).to receive(:glob).with(File.join(trusted_certs_dir, "*.{crt,pem}")).and_return([]) - expect(rendered_template).not_to match(%r{mkdir -p /etc/chef/trusted_certs}) + context "when :trusted_cets_dir is empty" do + let(:trusted_certs_dir) { Chef::Util::PathHelper.cleanpath(File.join(File.dirname(__FILE__), "../../data/trusted_certs_empty")) } + it "doesn't create /etc/chef/trusted_certs if :trusted_certs_dir is empty" do + expect(rendered_template).not_to match(%r{mkdir -p /etc/chef/trusted_certs}) + end end + end context "when doing fips things" do @@ -506,7 +514,7 @@ 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") @@ -531,7 +539,7 @@ describe Chef::Knife::Bootstrap do 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 + 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") @@ -540,14 +548,116 @@ describe Chef::Knife::Bootstrap do end end - describe "handling policyfile options" do + 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 "returns the value provided by the host argument" do + expect(knife.connection_protocol).to eq "ssh" + end + end + + 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 + + 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 + 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 "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 "#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 +668,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 +679,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 +690,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 +708,1342 @@ 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) do + { base_opts: true, + ssh_identity_opts: true, + ssh_opts: true, + gateway_opts: true, + host_verify_opts: true, + sudo_opts: true, + winrm_opts: true } + end + + 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][:connection_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) do + { + 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", + } + end + + 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) do + { + 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", + } + end + + 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[:connection_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[:connection_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) do + { + 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", + } + end + 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) do + { + logger: Chef::Log, + operation_timeout: 60, + self_signed: false, + ssl: false, + ssl_peer_fingerprint: nil, + winrm_basic_auth_only: false, + winrm_transport: "negotiate", + } + end + 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) do + { + 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, + } + end + + 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[:connection_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) do + { + 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 + } + 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 + knife.config[:max_wait] = 150 + knife.config[:connection_user] = "sshroot" + knife.config[:connection_port] = 1000 + knife.config[:connection_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) do + { + 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, + } + end + 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) do + { + forward_agent: false, + key_files: [], + logger: Chef::Log, + keys_only: false, + sudo: false, + verify_host_key: true, + } + end + 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[:connection_password] = "opscode" + end + + let(:expected_opts) do + { + port: 250, + user: "test", + logger: Chef::Log, + password: "opscode", + } + end + 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) do + { + port: 250, + user: "test", + logger: Chef::Log, + } + end + it "generates the correct options" do + expect(knife.base_opts).to eq expected_opts + end + end + end + end + + 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 + + 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 - 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 + # 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 + + 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[:connection_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 + + 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 - it "configures the hostname" do - expect(knife_ssh.name_args.first).to eq("foo.example.com") + 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 user" do - expect(knife_ssh.config[:ssh_user]).to eq("rooty") + 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 - it "configures the ssh password" do - expect(knife_ssh.config[:ssh_password]).to eq("open_sesame") + 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 - it "configures the ssh port" do - expect(knife_ssh.config[:ssh_port]).to eq("4001") + 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 + + context "when use_sudo_password is also set" do + before do + knife.config[:use_sudo_password] = true + knife.config[:connection_password] = "opscode" + end + it "includes :connection_password value in a sudo-enabled configuration" do + expect(knife.sudo_opts).to eq({ + sudo: true, + sudo_password: "opscode", + }) + end + 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 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 - 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 "#ssh_opts" do + let(:connection_protocol) { nil } + before do + allow(knife).to receive(:connection_protocol).and_return connection_protocol + end + + 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 + + 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) do + { + winrm_transport: "negotiate", + winrm_basic_auth_only: false, + ssl: false, + ssl_peer_fingerprint: nil, + operation_timeout: 60, + } end + + it "generates a correct configuration hash with expected defaults" do + expect(knife.winrm_opts).to eq expected + end + + context "with ssl_peer_fingerprint" do + let(:ssl_peer_fingerprint_expected) do + expected.merge({ ssl_peer_fingerprint: "ABCD" }) + end + + 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 + + context "with winrm_ssl" do + let(:ssl_expected) do + expected.merge({ ssl: true }) + end + 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 + + context "with winrm_auth_method" do + let(:winrm_auth_method_expected) do + expected.merge({ winrm_transport: "freeaccess" }) + end + + 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 + + context "with ca_trust_file" do + let(:ca_trust_expected) do + expected.merge({ ca_trust_file: "/trust.me" }) + end + 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 + + context "with kerberos auth" do + let(:kerberos_expected) do + expected.merge({ + kerberos_service: "testsvc", + kerberos_realm: "TESTREALM", + winrm_transport: "kerberos", + }) + end + + before do + knife.config[:winrm_auth_method] = "kerberos" + knife.config[:kerberos_service] = "testsvc" + knife.config[:kerberos_realm] = "TESTREALM" + end - it "configures the ssh user" do - expect(knife_ssh.config[:ssh_user]).to eq("curiosity") + 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 ssh port" do - expect(knife_ssh.config[:ssh_port]).to eq("2430") + context "with winrm_basic_auth_only" do + before do + knife.config[:winrm_basic_auth_only] = true + end + let(:basic_auth_expected) do + expected.merge( { winrm_basic_auth_only: true } ) + end + 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 - it "configures the ssh agent forwarding" do - expect(knife_ssh.config[:forward_agent]).to eq(true) + 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 "configures the ssh identity file" do - expect(knife_ssh.config[:ssh_identity_file]).to eq("~/.ssh/you.rsa") + it "performs the steps we expect to run a bootstrap" do + expect(knife).to receive(:warn_and_map_deprecated_flags).ordered + 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 "#warn_and_map_deprecated_flags" do + before do + Chef::Config[:silence_deprecation_warnings] = false + end + + context "when a deprecated CLI flag is given on the CLI" do + before do + knife.config[:ssh_user] = "sshuser" + knife.merge_configs + end + it "maps the key value to the new key and points the human to the new flag" do + expect(knife.ui).to receive(:warn).with(/--ssh-user USER is deprecated. Use --connection-user USERNAME instead./) + knife.warn_and_map_deprecated_flags + expect(knife.config[:connection_user]).to eq "sshuser" end + end - it "configures the ssh gateway" do - expect(knife_ssh.config[:ssh_gateway]).to eq("towel.blinkenlights.nl") + context "when a deprecated CLI flag is given on the CLI, along with its replacement" do + before do + knife.config[:ssh_user] = "sshuser" + knife.config[:connection_user] = "real-user" + knife.merge_configs end + it "warns that both are provided and takes the non-deprecated value" do + expect(knife.ui).to receive(:warn).with(/You provided both --connection-user and --ssh-user.*'--connection-user real-user'/m) + knife.warn_and_map_deprecated_flags + expect(knife.config[:connection_user]).to eq "real-user" + end + end + end - it "configures the ssh gateway identity" do - expect(knife_ssh.config[:ssh_gateway_identity]).to eq("~/.ssh/gateway.rsa") + 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 - it "configures the host key verify mode" do - expect(knife_ssh.config[:host_key_verify]).to eq(true) + 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 - 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 "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 - let(:client) { Chef::ApiClient.new } - context "when running with a configured and present validation key" do + context "when a non-auth-failure occurs" do + let(:expected_error) { RuntimeError.new } 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) + 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 - it "configures the underlying ssh command and then runs it" do - expect(knife_ssh).to receive(:run) - knife.run + context "when an auth failure occurs" do + let(:expected_error) do + # TODO This is awkward and ugly. Requires some refactor of chef_core/error + # to make it not so. See comment in rescue block of connect! for details. + e = RuntimeError.new + interim = RuntimeError.new + actual = Net::SSH::AuthenticationFailed.new + allow(interim).to receive(:cause).and_return(actual) + allow(e).to receive(:cause).and_return(interim) + e end - it "falls back to password based auth when auth fails the first time" do - allow(knife).to receive(:puts) + before do + require "net/ssh" + 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 + context "and password auth was used" do + before do + knife.config[:connection_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 "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) + context "and password auth was not used" do + before do + knife.config.delete :connection_password + allow(target_host).to receive(:user).and_return "testuser" + end + + 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 "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 + 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 + + 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_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 + 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 + + 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 "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 + 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 "raises an exception if the config[:chef_node_name] is not present" do - knife.config[:chef_node_name] = nil + 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 - expect { knife.run }.to raise_error(SystemExit) + 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 + + 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 - it "raises an exception if the config[:chef_node_name] is not present" do - knife.config[:chef_node_name] = nil + 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 "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 + + describe "validate_winrm_transport_opts!" do + before do + allow(knife).to receive(:connection_protocol).and_return connection_protocol + end - expect { knife.run }.to raise_error(SystemExit) + 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 diff --git a/spec/unit/knife/core/bootstrap_context_spec.rb b/spec/unit/knife/core/bootstrap_context_spec.rb index 5aa176557f..7b12177ab9 100644 --- a/spec/unit/knife/core/bootstrap_context_spec.rb +++ b/spec/unit/knife/core/bootstrap_context_spec.rb @@ -168,18 +168,6 @@ describe Chef::Knife::Core::BootstrapContext do end end - describe "when a pre-release bootstrap_version is specified" do - let(:chef_config) do - { - knife: { bootstrap_version: "11.12.4.rc.0" }, - } - end - - it "should send the full version to the installer and set the pre-release flag" do - expect(bootstrap_context.latest_current_chef_version_string).to eq("-v 11.12.4.rc.0 -p") - end - end - describe "when a bootstrap_version is not specified" do it "should send the latest current to the installer" do # Intentionally hard coded in order not to replicate the logic. @@ -218,15 +206,12 @@ describe Chef::Knife::Core::BootstrapContext do Chef::Config[:fips] = true end - it "adds the chef version check" do - expect(bootstrap_context.config_content).to include <<-CONFIG.gsub(/^ {8}/, "") - fips true - require "chef/version" - 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 + after do + Chef::Config.reset! + end + + it "sets fips mode in the client.rb" do + expect(bootstrap_context.config_content).to match(/fips true/) end end @@ -256,20 +241,6 @@ describe Chef::Knife::Core::BootstrapContext do end end - describe "prerelease" do - it "isn't set in the config_content by default" do - expect(bootstrap_context.config_content).not_to include("prerelease") - end - - describe "when configured via cli" do - let(:config) { { prerelease: true } } - - it "uses CLI value" do - expect(bootstrap_context.latest_current_chef_version_string).to eq("-p") - end - end - end - describe "#config_log_location" do context "when config_log_location is nil" do let(:chef_config) { { config_log_location: nil } } diff --git a/spec/unit/knife/core/windows_bootstrap_context_spec.rb b/spec/unit/knife/core/windows_bootstrap_context_spec.rb new file mode 100644 index 0000000000..3ab173f316 --- /dev/null +++ b/spec/unit/knife/core/windows_bootstrap_context_spec.rb @@ -0,0 +1,267 @@ +# +# Author:: Bryan McLellan <btm@loftninjas.org> +# Copyright:: Copyright (c) 2014-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 "spec_helper" +require "chef/knife/core/windows_bootstrap_context" +describe Chef::Knife::Core::WindowsBootstrapContext do + let(:config) { {} } + let(:bootstrap_context) { Chef::Knife::Core::WindowsBootstrapContext.new(config, nil, Chef::Config, nil) } + + describe "fips" do + before do + Chef::Config[:fips] = fips_mode + end + + after do + Chef::Config.reset! + end + + context "when fips is set" do + let(:fips_mode) { true } + + it "sets fips mode in the client.rb" do + expect(bootstrap_context.config_content).to match(/fips true/) + end + end + + context "when fips is not set" do + let(:fips_mode) { false } + + it "sets fips mode in the client.rb" do + expect(bootstrap_context.config_content).not_to match(/fips true/) + end + end + end + + describe "trusted_certs_script" do + let(:mock_cert_dir) { ::File.absolute_path(::File.join("spec", "assets", "fake_trusted_certs")) } + let(:script_output) { bootstrap_context.trusted_certs_script } + let(:crt_files) { ::Dir.glob(::File.join(mock_cert_dir, "*.crt")) } + let(:pem_files) { ::Dir.glob(::File.join(mock_cert_dir, "*.pem")) } + let(:other_files) { ::Dir.glob(::File.join(mock_cert_dir, "*")) - crt_files - pem_files } + + before do + bootstrap_context.instance_variable_set(:@chef_config, Mash.new(trusted_certs_dir: mock_cert_dir)) + end + + it "should echo every .crt file in the trusted_certs directory" do + crt_files.each do |f| + echo_file = ::File.read(f).gsub(/^/, "echo.") + expect(script_output).to include(::File.join("trusted_certs", ::File.basename(f))) + expect(script_output).to include(echo_file) + end + end + + it "should echo every .pem file in the trusted_certs directory" do + pem_files.each do |f| + echo_file = ::File.read(f).gsub(/^/, "echo.") + expect(script_output).to include(::File.join("trusted_certs", ::File.basename(f))) + expect(script_output).to include(echo_file) + end + end + + it "should not echo files which aren't .crt or .pem files" do + other_files.each do |f| + echo_file = ::File.read(f).gsub(/^/, "echo.") + expect(script_output).to_not include(::File.join("trusted_certs", ::File.basename(f))) + expect(script_output).to_not include(echo_file) + end + end + end + + describe "validation_key" do + before do + bootstrap_context.instance_variable_set(:@chef_config, Mash.new(validation_key: "C:\\chef\\key.pem")) + end + + it "should return false if validation_key does not exist" do + allow(::File).to receive(:expand_path).with("C:\\chef\\key.pem").and_call_original + allow(::File).to receive(:exist?).and_return(false) + expect(bootstrap_context.validation_key).to eq(false) + end + end + + describe "#get_log_location" do + + context "when config_log_location value is nil" do + it "sets STDOUT in client.rb as default" do + bootstrap_context.instance_variable_set(:@chef_config, Mash.new(config_log_location: nil)) + expect(bootstrap_context.get_log_location).to eq("STDOUT\n") + end + end + + context "when config_log_location value is empty" do + it "sets STDOUT in client.rb as default" do + bootstrap_context.instance_variable_set(:@chef_config, Mash.new(config_log_location: "")) + expect(bootstrap_context.get_log_location).to eq("STDOUT\n") + end + end + + context "when config_log_location value is STDOUT" do + it "sets STDOUT in client.rb" do + bootstrap_context.instance_variable_set(:@chef_config, Mash.new(config_log_location: STDOUT)) + expect(bootstrap_context.get_log_location).to eq("STDOUT\n") + end + end + + context "when config_log_location value is STDERR" do + it "sets STDERR in client.rb" do + bootstrap_context.instance_variable_set(:@chef_config, Mash.new(config_log_location: STDERR)) + expect(bootstrap_context.get_log_location).to eq("STDERR\n") + end + end + + context "when config_log_location value is path to a file" do + it "sets file path in client.rb" do + bootstrap_context.instance_variable_set(:@chef_config, Mash.new(config_log_location: "C:\\chef\\chef.log")) + expect(bootstrap_context.get_log_location).to eq("\"C:\\chef\\chef.log\"\n") + end + end + + context "when config_log_location value is :win_evt" do + it "sets :win_evt in client.rb" do + bootstrap_context.instance_variable_set(:@chef_config, Mash.new(config_log_location: :win_evt)) + expect(bootstrap_context.get_log_location).to eq(":win_evt\n") + end + end + + context "when config_log_location value is :syslog" do + it "raise error with message and exit" do + bootstrap_context.instance_variable_set(:@chef_config, Mash.new(config_log_location: :syslog)) + expect { bootstrap_context.get_log_location }.to raise_error("syslog is not supported for log_location on Windows OS\n") + end + end + + end + + describe "#config_content" do + before do + bootstrap_context.instance_variable_set(:@chef_config, Mash.new(config_log_level: :info, + config_log_location: STDOUT, + chef_server_url: "http://chef.example.com:4444", + validation_client_name: "chef-validator-testing", + file_cache_path: "c:/chef/cache", + file_backup_path: "c:/chef/backup", + cache_options: ({ path: "c:/chef/cache/checksums", skip_expires: true }) + )) + end + + it "generates the config file data" do + expected = <<~EXPECTED + echo.chef_server_url "http://chef.example.com:4444" + echo.validation_client_name "chef-validator-testing" + echo.file_cache_path "c:/chef/cache" + echo.file_backup_path "c:/chef/backup" + echo.cache_options ^({:path =^> "c:/chef/cache/checksums", :skip_expires =^> true}^) + echo.# Using default node name ^(fqdn^) + echo.log_level :info + echo.log_location STDOUT + EXPECTED + expect(bootstrap_context.config_content).to eq expected + end + end + + describe "latest_current_windows_chef_version_query" do + it "returns the major version of the current version of Chef" do + stub_const("Chef::VERSION", "15.1.2") + expect(bootstrap_context.latest_current_windows_chef_version_query).to eq("&v=15") + end + + end + + describe "msi_url" do + context "when config option is not set" do + before do + expect(bootstrap_context).to receive(:latest_current_windows_chef_version_query).and_return("&v=something") + end + + it "returns a chef.io msi url with minimal url parameters" do + reference_url = "https://www.chef.io/chef/download?p=windows&v=something" + expect(bootstrap_context.msi_url).to eq(reference_url) + end + + it "returns a chef.io msi url with provided url parameters substituted" do + reference_url = "https://www.chef.io/chef/download?p=windows&pv=machine&m=arch&DownloadContext=ctx&v=something" + expect(bootstrap_context.msi_url("machine", "arch", "ctx")).to eq(reference_url) + end + end + + context "when msi_url config option is set" do + let(:custom_url) { "file://something" } + let(:config) { { msi_url: custom_url, install: true } } + + it "returns the overriden url" do + expect(bootstrap_context.msi_url).to eq(custom_url) + end + + it "doesn't introduce any unnecessary query parameters if provided by the template" do + expect(bootstrap_context.msi_url("machine", "arch", "ctx")).to eq(custom_url) + end + end + end + + describe "bootstrap_install_command for bootstrap through WinRM" do + context "when bootstrap_install_command option is passed on CLI" do + let(:bootstrap) { Chef::Knife::Bootstrap.new(["--bootstrap-install-command", "chef-client"]) } + before do + bootstrap.config[:bootstrap_install_command] = "chef-client" + end + + it "sets the bootstrap_install_command option under Chef::Config::Knife object" do + expect(Chef::Config[:knife][:bootstrap_install_command]).to eq("chef-client") + end + + after do + bootstrap.config.delete(:bootstrap_install_command) + Chef::Config[:knife].delete(:bootstrap_install_command) + end + end + + context "when bootstrap_install_command option is not passed on CLI" do + let(:bootstrap) { Chef::Knife::Bootstrap.new([]) } + it "does not set the bootstrap_install_command option under Chef::Config::Knife object" do + expect(Chef::Config[:knife][:bootstrap_install_command]). to eq(nil) + end + end + end + + describe "bootstrap_install_command for bootstrap through SSH" do + context "when bootstrap_install_command option is passed on CLI" do + let(:bootstrap) { Chef::Knife::Bootstrap.new(["--bootstrap-install-command", "chef-client"]) } + before do + bootstrap.config[:bootstrap_install_command] = "chef-client" + end + + it "sets the bootstrap_install_command option under Chef::Config::Knife object" do + expect(Chef::Config[:knife][:bootstrap_install_command]).to eq("chef-client") + end + + after do + bootstrap.config.delete(:bootstrap_install_command) + Chef::Config[:knife].delete(:bootstrap_install_command) + end + end + + context "when bootstrap_install_command option is not passed on CLI" do + let(:bootstrap) { Chef::Knife::Bootstrap.new([]) } + it "does not set the bootstrap_install_command option under Chef::Config::Knife object" do + expect(Chef::Config[:knife][:bootstrap_install_command]). to eq(nil) + end + end + end +end diff --git a/spec/unit/knife_spec.rb b/spec/unit/knife_spec.rb index ff89bc6f30..f48ab4019c 100644 --- a/spec/unit/knife_spec.rb +++ b/spec/unit/knife_spec.rb @@ -299,7 +299,7 @@ describe Chef::Knife do expect(Chef::Config[:log_level]).to eql(:warn) end - it "prefers the default value if no config or command line value is present" do + it "prefers the default value if no config or command line value is present and reports the source as default" do knife_command = KnifeSpecs::TestYourself.new([]) # empty argv knife_command.configure_chef expect(knife_command.config[:opt_with_default]).to eq("default-value") @@ -310,18 +310,32 @@ describe Chef::Knife do knife_command = KnifeSpecs::TestYourself.new([]) # empty argv knife_command.configure_chef expect(knife_command.config[:opt_with_default]).to eq("from-knife-config") + expect(knife_command.config_source(:opt_with_default)).to eq (:config) end - it "prefers a value from command line over Chef::Config and the default" do + it "correctly reports Chef::Config as the source when a a config entry comes from there" do Chef::Config[:knife][:opt_with_default] = "from-knife-config" + knife_command = KnifeSpecs::TestYourself.new([]) # empty argv + knife_command.configure_chef + expect(knife_command.config_source(:opt_with_default)).to eq (:config) + end + + it "prefers a value from command line over Chef::Config and the default and reports the source as CLI" do knife_command = KnifeSpecs::TestYourself.new(["-D", "from-cli"]) knife_command.configure_chef expect(knife_command.config[:opt_with_default]).to eq("from-cli") + expect(knife_command.config_source(:opt_with_default)).to eq (:cli) + end + it "correctly reports CLI as the source when a config entry comes from the CLI" do + knife_command = KnifeSpecs::TestYourself.new(["-D", "from-cli"]) + knife_command.configure_chef + expect(knife_command.config_source(:opt_with_default)).to eq (:cli) end it "merges `listen` config to Chef::Config" do - Chef::Knife.run(%w{test yourself --no-listen}, Chef::Application::Knife.options) + knife_command = Chef::Knife.run(%w{test yourself --no-listen}, Chef::Application::Knife.options) expect(Chef::Config[:listen]).to be(false) + expect(knife_command.config_source(:listen)).to eq(:cli) end context "verbosity is one" do |