Last active
March 28, 2019 08:16
-
-
Save clairvy/206bcf57e791ec70b60fab6a54dc67f0 to your computer and use it in GitHub Desktop.
modify to use identityfile setting in .ssh/config
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# | |
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# | |
# 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://[email protected]" } | |
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