Skip to content

Instantly share code, notes, and snippets.

@clairvy
Last active March 28, 2019 08:16
Show Gist options
  • Save clairvy/206bcf57e791ec70b60fab6a54dc67f0 to your computer and use it in GitHub Desktop.
Save clairvy/206bcf57e791ec70b60fab6a54dc67f0 to your computer and use it in GitHub Desktop.
modify to use identityfile setting in .ssh/config
#
# Copyright:: Copyright (c) 2017-2019 Chef Software Inc.
# License:: Apache License, Version 2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
require "chef_apply/log"
require "chef_apply/error"
require "train"
module ChefApply
class TargetHost
attr_reader :config, :reporter, :backend, :transport_type
# These values may exist in .ssh/config but will be ignored by train
# in favor of its defaults unless we specify them explicitly.
# See #apply_ssh_config
SSH_CONFIG_OVERRIDE_KEYS_MAP = [
[:user, :user, :user],
[:port, :port, :port],
[:proxy, :proxy, :proxy],
[:keys, :key_files, :identityfile],
].freeze
SSH_CONFIG_OVERRIDE_KEYS = SSH_CONFIG_OVERRIDE_KEYS_MAP.map { |e| e[0] }.freeze
# We're borrowing a page from train here - because setting up a
# reliable connection for testing is a multi-step process,
# we'll provide this method which instantiates a TargetHost connected
# to a train mock backend. If the family/name provided resolves to a suported
# OS, this instance will mix-in the supporting methods for the given platform;
# otherwise those methods will raise NotImplementedError.
def self.mock_instance(url, family: "unknown", name: "unknown",
release: "unknown", arch: "x86_64")
# Specifying sudo: false ensures that attempted operations
# don't fail because the mock platform doesn't support sudo
target_host = TargetHost.new(url, { sudo: false })
# Don't pull in the platform-specific mixins automatically during connect
# Otherwise, it will raise since it can't resolve the OS without the mock.
target_host.instance_variable_set(:@mocked_connection, true)
target_host.connect!
# We need to provide this mock before invoking mix_in_target_platform,
# otherwise it will fail with an unknown OS (since we don't have a real connection).
target_host.backend.mock_os(
family: family,
name: name,
release: release,
arch: arch
)
# Only mix-in if we can identify the platform. This
# prevents mix_in_target_platform! from raising on unknown platform during
# tests that validate unsupported platform behaviors.
if target_host.base_os != :other
target_host.mix_in_target_platform!
end
target_host
end
def initialize(host_url, opts = {}, logger = nil)
@config = connection_config(host_url, opts, logger)
@transport_type = Train.validate_backend(@config)
apply_ssh_config(@config, opts) if @transport_type == "ssh"
@train_connection = Train.create(@transport_type, config)
end
def connection_config(host_url, opts_in, logger)
connection_opts = { target: host_url,
sudo: opts_in[:sudo] === false ? false : true,
www_form_encoded_password: true,
key_files: opts_in[:identity_file],
non_interactive: true,
# Prevent long delays due to retries on auth failure.
# This does reduce the number of attempts we'll make for transient conditions as well, but
# train does not currently exposes these as separate controls. Ideally I'd like to see a 'retry_on_auth_failure' option.
connection_retries: 2,
connection_retry_sleep: 0.15,
logger: ChefApply::Log }
if opts_in.key? :ssl
connection_opts[:ssl] = opts_in[:ssl]
connection_opts[:self_signed] = (opts_in[:ssl_verify] === false ? true : false)
end
[:sudo_password, :sudo, :sudo_command, :password, :user].each do |key|
connection_opts[key] = opts_in[key] if opts_in.key? key
end
Train.target_config(connection_opts)
end
def apply_ssh_config(config, opts_in)
# If we don't provide certain options, they will be defaulted
# within train - in the case of ssh, this will prevent the .ssh/config
# values from being picked up.
# Here we'll modify the returned @config to specify
# values that we get out of .ssh/config if present and if they haven't
# been explicitly given.
host_cfg = ssh_config_for_host(config[:host])
SSH_CONFIG_OVERRIDE_KEYS_MAP.each do |host_key, config_key, opt_key|
if host_cfg.key?(host_key) && opts_in[opt_key].nil?
config[config_key] = host_cfg[host_key]
end
end
end
# Establish connection to configured target.
#
def connect!
# Keep existing connections
return unless @backend.nil?
@backend = train_connection.connection
@backend.wait_until_ready
# When the testing function `mock_instance` is used, it will set
# this instance variable to false and handle this function call
# after the platform data is mocked; this will allow binding
# of mixin functions based on the mocked platform.
mix_in_target_platform! unless @mocked_connection
rescue Train::UserError => e
raise ConnectionFailure.new(e, config)
rescue Train::Error => e
# These are typically wrapper errors for other problems,
# so we'll prefer to use e.cause over e if available.
raise ConnectionFailure.new(e.cause || e, config)
end
def mix_in_target_platform!
case base_os
when :linux
require "chef_apply/target_host/linux"
class << self; include ChefApply::TargetHost::Linux; end
when :windows
require "chef_apply/target_host/windows"
class << self; include ChefApply::TargetHost::Windows; end
when :other
raise ChefApply::TargetHost::UnsupportedTargetOS.new(platform.name)
end
end
# Returns the user being used to connect. Defaults to train's default user if not specified
def user
return config[:user] unless config[:user].nil?
require "train/transports/ssh"
Train::Transports::SSH.default_options[:user][:default]
end
def hostname
config[:host]
end
def architecture
platform.arch
end
def version
platform.release
end
def base_os
if platform.windows?
:windows
elsif platform.linux?
:linux
else
:other
end
end
# TODO 2019-01-29 not expose this, it's internal implemenation. Same with #backend.
def platform
backend.platform
end
def run_command!(command)
result = run_command(command)
if result.exit_status != 0
raise RemoteExecutionFailed.new(@config[:host], command, result)
end
result
end
def run_command(command)
backend.run_command command
end
def upload_file(local_path, remote_path)
backend.upload(local_path, remote_path)
end
# Retrieve the contents of a remote file. Returns nil
# if the file didn't exist or couldn't be read.
def fetch_file_contents(remote_path)
result = backend.file(remote_path)
if result.exist? && result.file?
result.content
else
nil
end
end
# Returns the installed chef version as a Gem::Version,
# or raised ChefNotInstalled if chef client version manifest can't
# be found.
def installed_chef_version
return @installed_chef_version if @installed_chef_version
# Note: In the case of a very old version of chef (that has no manifest - pre 12.0?)
# this will report as not installed.
manifest = read_chef_version_manifest()
# We split the version here because unstable builds install from)
# are in the form "Major.Minor.Build+HASH" which is not a valid
# version string.
@installed_chef_version = Gem::Version.new(manifest["build_version"].split("+")[0])
end
def read_chef_version_manifest
manifest = fetch_file_contents(omnibus_manifest_path)
raise ChefNotInstalled.new if manifest.nil?
JSON.parse(manifest)
end
# Creates and caches location of temporary directory on the remote host
# using platform-specific implementations of make_temp_dir
# This will also set ownership to the connecting user instead of default of
# root when sudo'd, so that the dir can be used to upload files using scp
# as the connecting user.
#
# The base temp dir is cached and will only be created once per connection lifetime.
def temp_dir
dir = make_temp_dir()
chown(dir, user)
dir
end
# create a directory. because we run all commands as root, this will also set group:owner
# to the connecting user if host isn't windows so that scp -- which uses the connecting user --
# will have permissions to upload into it.
def make_directory(path)
mkdir(path)
chown(path, user)
path
end
# normalizes path across OS's
def normalize_path(p) # NOTE BOOTSTRAP: was action::base::escape_windows_path
p.tr("\\", "/")
end
# Simplified chown - just sets user, defaults to connection user. Does not touch
# group. Only has effect on non-windows targets
def chown(path, owner); raise NotImplementedError; end
# Platform-specific installation of packages
def install_package(target_package_path); raise NotImplementedError; end
def ws_cache_path; raise NotImplementedError; end
# Recursively delete directory
def del_dir(path); raise NotImplementedError; end
def del_file(path); raise NotImplementedError; end
def omnibus_manifest_path(); raise NotImplementedError; end
private
def train_connection
@train_connection
end
def ssh_config_for_host(host)
require "net/ssh"
Net::SSH::Config.for(host)
end
class RemoteExecutionFailed < ChefApply::ErrorNoLogs
attr_reader :stdout, :stderr
def initialize(host, command, result)
super("CHEFRMT001",
command,
result.exit_status,
host,
result.stderr.empty? ? result.stdout : result.stderr)
end
end
class ConnectionFailure < ChefApply::ErrorNoLogs
# TODO: Currently this only handles sudo-related errors;
# we should also look at e.cause for underlying connection errors
# which are presently only visible in log files.
def initialize(original_exception, connection_opts)
sudo_command = connection_opts[:sudo_command]
init_params =
# Comments below show the original_exception.reason values to check for instead of strings,
# after train 1.4.12 is consumable.
case original_exception.message # original_exception.reason
when /Sudo requires a password/ # :sudo_password_required
"CHEFTRN003"
when /Wrong sudo password/ #:bad_sudo_password
"CHEFTRN004"
when /Can't find sudo command/, /No such file/, /command not found/ # :sudo_command_not_found
# NOTE: In the /No such file/ case, reason will be nil - we still have
# to check message text. (Or PR to train to handle this case)
["CHEFTRN005", sudo_command] # :sudo_command_not_found
when /Sudo requires a TTY.*/ # :sudo_no_tty
"CHEFTRN006"
when /has no keys added/
"CHEFTRN007"
else
["CHEFTRN999", original_exception.message]
end
super(*(Array(init_params).flatten))
end
end
class ChefNotInstalled < StandardError; end
class UnsupportedTargetOS < ChefApply::ErrorNoLogs
def initialize(os_name); super("CHEFTARG001", os_name); end
end
end
end
#
# Copyright:: Copyright (c) 2018-2019 Chef Software Inc.
# License:: Apache License, Version 2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
require "spec_helper"
require "ostruct"
require "chef_apply/target_host"
RSpec.describe ChefApply::TargetHost do
let(:host) { "mock://user@example.com" }
let(:family) { "debian" }
let(:name) { "ubuntu" }
subject do
ChefApply::TargetHost.mock_instance(host, family: family, name: name)
end
context "#base_os" do
context "for a windows os" do
let(:family) { "windows" }
let(:name) { "windows" }
it "reports :windows" do
expect(subject.base_os).to eq :windows
end
end
context "for a linux os" do
let(:family) { "debian" }
let(:name) { "ubuntu" }
it "reports :linux" do
expect(subject.base_os).to eq :linux
end
end
context "for an unsupported OS" do
let(:family) { "unknown" }
let(:name) { "unknown" }
it "reports :other" do
expect(subject.base_os).to eq :other
end
end
end
context "#installed_chef_version" do
context "when no version manifest is present" do
it "raises ChefNotInstalled" do
expect(subject).to receive(:read_chef_version_manifest).and_raise(ChefApply::TargetHost::ChefNotInstalled.new)
expect { subject.installed_chef_version }.to raise_error(ChefApply::TargetHost::ChefNotInstalled)
end
end
context "when version manifest is present" do
let(:manifest) { { "build_version" => "14.0.1" } }
it "reports version based on the build_version field" do
expect(subject).to receive(:read_chef_version_manifest).and_return manifest
expect(subject.installed_chef_version).to eq Gem::Version.new("14.0.1")
end
end
end
context "connect!" do
# For all other tets, target_host is a mocked instance that is already connected
# In this case, we want to build a new one that is not yet connected to test connect! itself.
let(:target_host) { ChefApply::TargetHost.new(host, sudo: false) }
let(:train_connection_mock) { double("train connection") }
before do
allow(target_host).to receive(:train_connection).and_return(train_connection_mock)
end
context "when an Train::UserError occurs" do
it "raises a ConnectionFailure" do
allow(train_connection_mock).to receive(:connection).and_raise Train::UserError
expect { target_host.connect! }.to raise_error(ChefApply::TargetHost::ConnectionFailure)
end
end
context "when a Train::Error occurs" do
it "raises a ConnectionFailure" do
allow(train_connection_mock).to receive(:connection).and_raise Train::Error
expect { target_host.connect! }.to raise_error(ChefApply::TargetHost::ConnectionFailure)
end
end
end
context "#mix_in_target_platform!" do
let(:base_os) { :none }
before do
allow(subject).to receive(:base_os).and_return base_os
end
context "when base_os is linux" do
let(:base_os) { :linux }
it "mixes in Linux support" do
expect(subject.class).to receive(:include).with(ChefApply::TargetHost::Linux)
subject.mix_in_target_platform!
end
end
context "when base_os is windows" do
let(:base_os) { :windows }
it "mixes in Windows support" do
expect(subject.class).to receive(:include).with(ChefApply::TargetHost::Windows)
subject.mix_in_target_platform!
end
end
context "when base_os is other" do
let(:base_os) { :other }
it "raises UnsupportedTargetOS" do
expect { subject.mix_in_target_platform! }.to raise_error(ChefApply::TargetHost::UnsupportedTargetOS)
end
end
context "after it connects" do
context "to a Windows host" do
it "includes the Windows TargetHost mixin" do
end
end
context "and the platform is linux" do
it "includes the Windows TargetHost mixin" do
end
end
end
end
context "#user" do
before do
allow(subject).to receive(:config).and_return(user: user)
end
context "when a user has been configured" do
let(:user) { "testuser" }
it "returns that user" do
expect(subject.user).to eq user
end
end
context "when no user has been configured" do
let(:user) { nil }
it "returns the correct default from train" do
expect(subject.user).to eq Train::Transports::SSH.default_options[:user][:default]
end
end
end
context "#run_command!" do
let(:backend) { double("backend") }
let(:exit_status) { 0 }
let(:result) { RemoteExecResult.new(exit_status, "", "an error occurred") }
let(:command) { "cmd" }
before do
allow(subject).to receive(:backend).and_return(backend)
allow(backend).to receive(:run_command).with(command).and_return(result)
end
context "when no error occurs" do
let(:exit_status) { 0 }
it "returns the result" do
expect(subject.run_command!(command)).to eq result
end
end
context "when an error occurs" do
let(:exit_status) { 1 }
it "raises a RemoteExecutionFailed error" do
expected_error = ChefApply::TargetHost::RemoteExecutionFailed
expect { subject.run_command!(command) }.to raise_error(expected_error)
end
end
end
context "#read_chef_version_manifest" do
let(:manifest_content) { '{"build_version" : "1.2.3"}' }
before do
allow(subject).to receive(:fetch_file_contents).and_return(manifest_content)
allow(subject).to receive(:omnibus_manifest_path).and_return("/path/to/manifest.json")
end
context "when manifest is missing" do
let(:manifest_content) { nil }
it "raises ChefNotInstalled" do
expect { subject.read_chef_version_manifest }.to raise_error(ChefApply::TargetHost::ChefNotInstalled)
end
end
context "when manifest is present" do
let(:manifest_content) { '{"build_version" : "1.2.3"}' }
it "should return the parsed manifest" do
expect(subject.read_chef_version_manifest).to eq({ "build_version" => "1.2.3" })
end
end
end
# What we test:
# - file contents can be retrieved, and invalid conditions results in no content
# What we mock:
# - the train `backend`
# - the backend `file` method
# Why?
# - in this unit test, we're not testing round-trip behavior of the train API, only
# that we are invoking the API and interpreting its results correctly.
context "#fetch_file_contents" do
let(:path) { "/path/to/file" }
let(:sample_content) { "content" }
let(:backend_mock) { double("backend") }
let(:path_exists) { true }
let(:path_is_file) { true }
let(:remote_file_mock) do
double("remote_file", exist?: path_exists,
file?: path_is_file, content: sample_content) end
before do
expect(subject).to receive(:backend).and_return backend_mock
expect(backend_mock).to receive(:file).with(path).and_return remote_file_mock
end
context "when path exists" do
let(:path_exists) { true }
before do
end
context "but is not a file" do
let(:path_is_file) { false }
it "returns nil" do
expect(subject.fetch_file_contents(path)).to be_nil
end
end
context "and is a file" do
it "returns the expected file contents" do
expect(subject.fetch_file_contents(path)).to eq sample_content
end
end
end
context "when path does not exist" do
let(:path_exists) { false }
it "returns nil" do
expect(subject.fetch_file_contents(path)).to be_nil
end
end
end
context "#apply_ssh_config" do
let(:ssh_host_config) { { user: "testuser", port: 1000, proxy: double("Net:SSH::Proxy::Command"), keys: ["use key"] } }
let(:connection_config) { { user: "user1", port: 8022, proxy: nil, key_files: nil } }
before do
allow(subject).to receive(:ssh_config_for_host).and_return ssh_host_config
end
ChefApply::TargetHost::SSH_CONFIG_OVERRIDE_KEYS_MAP.each do |host_key, config_key, opt_key|
context "when a value is not explicitly provided in options" do
it "replaces config config[:#{config_key}] with the ssh config value" do
subject.apply_ssh_config(connection_config, config_key => nil)
expect(connection_config[config_key]).to eq(ssh_host_config[host_key])
end
end
context "when a value is explicitly provided in options" do
it "the connection configuration isnot updated with a value from ssh config" do
original_config = connection_config.clone
subject.apply_ssh_config(connection_config, { opt_key => "testvalue" } )
expect(connection_config[config_key]).to eq original_config[config_key]
end
end
end
end
context "#temp_dir" do
it "creates the temp directory and changes ownership" do
expect(subject).to receive(:make_temp_dir).and_return("/tmp/dir")
expect(subject).to receive(:chown).with("/tmp/dir", subject.user)
subject.temp_dir()
end
end
context "#make_directory" do
it "creates the directory and sets ownership to connecting user" do
expect(subject).to receive(:mkdir).with("/tmp/mkdir")
expect(subject).to receive(:chown).with("/tmp/mkdir", subject.user)
subject.make_directory("/tmp/mkdir")
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment