chef-apply 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +423 -0
- data/LICENSE +201 -0
- data/README.md +41 -0
- data/Rakefile +32 -0
- data/bin/chef-run +23 -0
- data/chef-apply.gemspec +67 -0
- data/i18n/en.yml +513 -0
- data/lib/chef_apply.rb +20 -0
- data/lib/chef_apply/action/base.rb +158 -0
- data/lib/chef_apply/action/converge_target.rb +173 -0
- data/lib/chef_apply/action/install_chef.rb +30 -0
- data/lib/chef_apply/action/install_chef/base.rb +137 -0
- data/lib/chef_apply/action/install_chef/linux.rb +38 -0
- data/lib/chef_apply/action/install_chef/windows.rb +54 -0
- data/lib/chef_apply/action/reporter.rb +39 -0
- data/lib/chef_apply/cli.rb +470 -0
- data/lib/chef_apply/cli_options.rb +145 -0
- data/lib/chef_apply/config.rb +150 -0
- data/lib/chef_apply/error.rb +108 -0
- data/lib/chef_apply/errors/ccr_failure_mapper.rb +93 -0
- data/lib/chef_apply/file_fetcher.rb +70 -0
- data/lib/chef_apply/log.rb +42 -0
- data/lib/chef_apply/recipe_lookup.rb +117 -0
- data/lib/chef_apply/startup.rb +162 -0
- data/lib/chef_apply/status_reporter.rb +42 -0
- data/lib/chef_apply/target_host.rb +233 -0
- data/lib/chef_apply/target_resolver.rb +202 -0
- data/lib/chef_apply/telemeter.rb +162 -0
- data/lib/chef_apply/telemeter/patch.rb +32 -0
- data/lib/chef_apply/telemeter/sender.rb +121 -0
- data/lib/chef_apply/temp_cookbook.rb +159 -0
- data/lib/chef_apply/text.rb +77 -0
- data/lib/chef_apply/ui/error_printer.rb +261 -0
- data/lib/chef_apply/ui/plain_text_element.rb +75 -0
- data/lib/chef_apply/ui/terminal.rb +94 -0
- data/lib/chef_apply/version.rb +20 -0
- metadata +376 -0
@@ -0,0 +1,70 @@
|
|
1
|
+
#
|
2
|
+
# Copyright:: Copyright (c) 2017 Chef Software Inc.
|
3
|
+
# License:: Apache License, Version 2.0
|
4
|
+
#
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
6
|
+
# you may not use this file except in compliance with the License.
|
7
|
+
# You may obtain a copy of the License at
|
8
|
+
#
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
10
|
+
#
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
14
|
+
# See the License for the specific language governing permissions and
|
15
|
+
# limitations under the License.
|
16
|
+
#
|
17
|
+
|
18
|
+
require "net/http"
|
19
|
+
require "uri"
|
20
|
+
require "chef_apply/config"
|
21
|
+
require "chef_apply/log"
|
22
|
+
|
23
|
+
module ChefApply
|
24
|
+
class FileFetcher
|
25
|
+
class << self
|
26
|
+
# Simple fetcher of an http(s) url. Returns the local path
|
27
|
+
# of the downloaded file.
|
28
|
+
def fetch(path)
|
29
|
+
cache_path = ChefApply::Config.cache.path
|
30
|
+
FileUtils.mkdir_p(cache_path)
|
31
|
+
url = URI.parse(path)
|
32
|
+
name = File.basename(url.path)
|
33
|
+
local_path = File.join(cache_path, name)
|
34
|
+
|
35
|
+
# TODO header check for size or checksum?
|
36
|
+
return local_path if File.exist?(local_path)
|
37
|
+
|
38
|
+
download_file(url, local_path)
|
39
|
+
local_path
|
40
|
+
end
|
41
|
+
|
42
|
+
def download_file(url, local_path)
|
43
|
+
temp_path = "#{local_path}.downloading"
|
44
|
+
file = open(temp_path, "wb")
|
45
|
+
ChefApply::Log.debug "Downloading: #{temp_path}"
|
46
|
+
Net::HTTP.start(url.host) do |http|
|
47
|
+
begin
|
48
|
+
http.request_get(url.path) do |resp|
|
49
|
+
resp.read_body do |segment|
|
50
|
+
file.write(segment)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
rescue e
|
54
|
+
@error = true
|
55
|
+
raise
|
56
|
+
ensure
|
57
|
+
file.close()
|
58
|
+
# If any failures occurred, don't risk keeping
|
59
|
+
# an incomplete download that we'll see as 'cached'
|
60
|
+
if @error
|
61
|
+
FileUtils.rm_f(temp_path)
|
62
|
+
else
|
63
|
+
FileUtils.mv(temp_path, local_path)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
#
|
2
|
+
# Copyright:: Copyright (c) 2017 Chef Software Inc.
|
3
|
+
# License:: Apache License, Version 2.0
|
4
|
+
#
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
6
|
+
# you may not use this file except in compliance with the License.
|
7
|
+
# You may obtain a copy of the License at
|
8
|
+
#
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
10
|
+
#
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
14
|
+
# See the License for the specific language governing permissions and
|
15
|
+
# limitations under the License.
|
16
|
+
#
|
17
|
+
|
18
|
+
require "mixlib/log"
|
19
|
+
|
20
|
+
module ChefApply
|
21
|
+
class Log
|
22
|
+
extend Mixlib::Log
|
23
|
+
|
24
|
+
def self.setup(location, log_level)
|
25
|
+
@location = location
|
26
|
+
if location.is_a?(String)
|
27
|
+
if location.casecmp("stdout") == 0
|
28
|
+
location = $stdout
|
29
|
+
else
|
30
|
+
location = File.open(location, "w+")
|
31
|
+
end
|
32
|
+
end
|
33
|
+
init(location)
|
34
|
+
Log.level = log_level
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.location
|
38
|
+
@location
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,117 @@
|
|
1
|
+
#
|
2
|
+
# Copyright:: Copyright (c) 2017 Chef Software Inc.
|
3
|
+
# License:: Apache License, Version 2.0
|
4
|
+
#
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
6
|
+
# you may not use this file except in compliance with the License.
|
7
|
+
# You may obtain a copy of the License at
|
8
|
+
#
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
10
|
+
#
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
14
|
+
# See the License for the specific language governing permissions and
|
15
|
+
# limitations under the License.
|
16
|
+
#
|
17
|
+
|
18
|
+
require "chef-config/config"
|
19
|
+
require "chef_apply/config"
|
20
|
+
require "chef_apply/error"
|
21
|
+
require "chef_apply/log"
|
22
|
+
|
23
|
+
module ChefApply
|
24
|
+
# When users are trying to converge a local recipe on a remote target, there
|
25
|
+
# is a very specific (but expansive) set of things they can specify. This
|
26
|
+
# class encapsulates that logic for testing purposes. We either return
|
27
|
+
# a path to a recipe or we raise an error.
|
28
|
+
class RecipeLookup
|
29
|
+
|
30
|
+
attr_reader :cookbook_repo_paths
|
31
|
+
def initialize(cookbook_repo_paths)
|
32
|
+
@cookbook_repo_paths = cookbook_repo_paths
|
33
|
+
end
|
34
|
+
|
35
|
+
# The recipe specifier is provided by the customer as either a path OR
|
36
|
+
# a cookbook and optional recipe name.
|
37
|
+
def split(recipe_specifier)
|
38
|
+
recipe_specifier.split("::")
|
39
|
+
end
|
40
|
+
|
41
|
+
# Given a cookbook path or name, try to load that cookbook. Either return
|
42
|
+
# a cookbook object or raise an error.
|
43
|
+
def load_cookbook(path_or_name)
|
44
|
+
require "chef/exceptions"
|
45
|
+
if File.directory?(path_or_name)
|
46
|
+
cookbook_path = path_or_name
|
47
|
+
# First, is there a cookbook in the specified dir that matches?
|
48
|
+
require "chef/cookbook/cookbook_version_loader"
|
49
|
+
begin
|
50
|
+
v = Chef::Cookbook::CookbookVersionLoader.new(cookbook_path)
|
51
|
+
v.load!
|
52
|
+
cookbook = v.cookbook_version
|
53
|
+
rescue Chef::Exceptions::CookbookNotFoundInRepo
|
54
|
+
raise InvalidCookbook.new(cookbook_path)
|
55
|
+
end
|
56
|
+
else
|
57
|
+
cookbook_name = path_or_name
|
58
|
+
# Second, is there a cookbook in their local repository that matches?
|
59
|
+
require "chef/cookbook_loader"
|
60
|
+
cb_loader = Chef::CookbookLoader.new(cookbook_repo_paths)
|
61
|
+
cb_loader.load_cookbooks_without_shadow_warning
|
62
|
+
|
63
|
+
begin
|
64
|
+
cookbook = cb_loader[cookbook_name]
|
65
|
+
rescue Chef::Exceptions::CookbookNotFoundInRepo
|
66
|
+
cookbook_repo_paths.each do |repo_path|
|
67
|
+
cookbook_path = File.join(repo_path, cookbook_name)
|
68
|
+
if File.directory?(cookbook_path)
|
69
|
+
raise InvalidCookbook.new(cookbook_path)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
raise CookbookNotFound.new(cookbook_name, cookbook_repo_paths)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
cookbook
|
76
|
+
end
|
77
|
+
|
78
|
+
# Find the specified recipe or default recipe if none is specified.
|
79
|
+
# Raise an error if recipe cannot be found.
|
80
|
+
def find_recipe(cookbook, recipe_name = nil)
|
81
|
+
recipes = cookbook.recipe_filenames_by_name
|
82
|
+
if recipe_name.nil?
|
83
|
+
default_recipe = recipes["default"]
|
84
|
+
raise NoDefaultRecipe.new(cookbook.root_dir, cookbook.name) if default_recipe.nil?
|
85
|
+
default_recipe
|
86
|
+
else
|
87
|
+
recipe = recipes[recipe_name]
|
88
|
+
raise RecipeNotFound.new(cookbook.root_dir, recipe_name, recipes.keys, cookbook.name) if recipe.nil?
|
89
|
+
recipe
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
class InvalidCookbook < ChefApply::Error
|
94
|
+
def initialize(cookbook_path); super("CHEFVAL005", cookbook_path); end
|
95
|
+
end
|
96
|
+
|
97
|
+
class CookbookNotFound < ChefApply::Error
|
98
|
+
def initialize(cookbook_name, repo_paths)
|
99
|
+
repo_paths = repo_paths.join("\n")
|
100
|
+
super("CHEFVAL006", cookbook_name, repo_paths)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
class NoDefaultRecipe < ChefApply::Error
|
105
|
+
def initialize(cookbook_path, cookbook_name); super("CHEFVAL007", cookbook_path, cookbook_name); end
|
106
|
+
end
|
107
|
+
|
108
|
+
class RecipeNotFound < ChefApply::Error
|
109
|
+
def initialize(cookbook_path, recipe_name, available_recipes, cookbook_name)
|
110
|
+
available_recipes.map! { |r| "'#{r}'" }
|
111
|
+
available_recipes = available_recipes.join(", ")
|
112
|
+
super("CHEFVAL008", cookbook_path, recipe_name, available_recipes, cookbook_name)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
end
|
117
|
+
end
|
@@ -0,0 +1,162 @@
|
|
1
|
+
#
|
2
|
+
# Copyright:: Copyright (c) 2018 Chef Software Inc.
|
3
|
+
# License:: Apache License, Version 2.0
|
4
|
+
#
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
6
|
+
# you may not use this file except in compliance with the License.
|
7
|
+
# You may obtain a copy of the License at
|
8
|
+
#
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
10
|
+
#
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
14
|
+
# See the License for the specific language governing permissions and
|
15
|
+
# limitations under the License.
|
16
|
+
|
17
|
+
require "chef_apply/config"
|
18
|
+
require "chef_apply/text"
|
19
|
+
require "chef_apply/ui/terminal"
|
20
|
+
require "chef_apply/telemeter/sender"
|
21
|
+
module ChefApply
|
22
|
+
class Startup
|
23
|
+
attr_reader :argv
|
24
|
+
T = ChefApply::Text.cli
|
25
|
+
|
26
|
+
def initialize(argv)
|
27
|
+
@term_init = false
|
28
|
+
@argv = argv.clone
|
29
|
+
# Enable CLI output via Terminal. This comes first because other startup steps may
|
30
|
+
# need to output to the terminal.
|
31
|
+
init_terminal
|
32
|
+
end
|
33
|
+
|
34
|
+
def run
|
35
|
+
# Some tasks we do only once in an installation:
|
36
|
+
first_run_tasks
|
37
|
+
|
38
|
+
# Call this every time, so that if we add or change ~/.chef-workstation
|
39
|
+
# directory structure, we can be sure that it exists. Even with a
|
40
|
+
# custom configuration, the .chef-workstation directory and subdirs
|
41
|
+
# are required.
|
42
|
+
setup_workstation_user_directories
|
43
|
+
|
44
|
+
# Startup tasks that may change behavior based on configuration value
|
45
|
+
# must be run after load_config
|
46
|
+
load_config
|
47
|
+
|
48
|
+
# Init logging using log level out of config
|
49
|
+
setup_logging
|
50
|
+
|
51
|
+
# Begin upload of previous session telemetry. (If telemetry is not enabled,
|
52
|
+
# in config the uploader will clean up previous session(s) without sending)
|
53
|
+
start_telemeter_upload
|
54
|
+
|
55
|
+
# Launch the actual Chef Apply behavior
|
56
|
+
start_chef_apply
|
57
|
+
|
58
|
+
# NOTE: Because these exceptions occur outside of the
|
59
|
+
# CLI handling, they won't be tracked in telemtry.
|
60
|
+
# We can revisit this once the pending error handling rework
|
61
|
+
# is underway.
|
62
|
+
rescue ConfigPathInvalid => e
|
63
|
+
UI::Terminal.output(T.error.bad_config_file(e.path))
|
64
|
+
rescue ConfigPathNotProvided
|
65
|
+
UI::Terminal.output(T.error.missing_config_path)
|
66
|
+
rescue Mixlib::Config::UnknownConfigOptionError => e
|
67
|
+
# Ideally we'd update the exception in mixlib to include
|
68
|
+
# a field with the faulty value, line number, and nested context -
|
69
|
+
# it's less fragile than depending on text parsing, which
|
70
|
+
# is what we'll do for now.
|
71
|
+
if e.message =~ /.*unsupported config value (.*)[.]+$/
|
72
|
+
# TODO - levenshteinian distance to figure out
|
73
|
+
# what they may have meant instead.
|
74
|
+
UI::Terminal.output(T.error.invalid_config_key($1, Config.location))
|
75
|
+
else
|
76
|
+
# Safety net in case the error text changes from under us.
|
77
|
+
UI::Terminal.output(T.error.unknown_config_error(e.message, Config.location))
|
78
|
+
end
|
79
|
+
rescue Tomlrb::ParseError => e
|
80
|
+
UI::Terminal.output(T.error.unknown_config_error(e.message, Config.location))
|
81
|
+
end
|
82
|
+
|
83
|
+
def init_terminal
|
84
|
+
UI::Terminal.init($stdout)
|
85
|
+
end
|
86
|
+
|
87
|
+
def first_run_tasks
|
88
|
+
return if Dir.exist?(Config::WS_BASE_PATH)
|
89
|
+
create_default_config
|
90
|
+
setup_telemetry
|
91
|
+
end
|
92
|
+
|
93
|
+
def create_default_config
|
94
|
+
UI::Terminal.output T.creating_config(Config.default_location)
|
95
|
+
UI::Terminal.output ""
|
96
|
+
FileUtils.mkdir_p(Config::WS_BASE_PATH)
|
97
|
+
FileUtils.touch(Config.default_location)
|
98
|
+
end
|
99
|
+
|
100
|
+
def setup_telemetry
|
101
|
+
require "securerandom"
|
102
|
+
installation_id = SecureRandom.uuid
|
103
|
+
File.write(Config.telemetry_installation_identifier_file, installation_id)
|
104
|
+
|
105
|
+
# Tell the user we're anonymously tracking, give brief opt-out
|
106
|
+
# and a link to detailed information.
|
107
|
+
UI::Terminal.output T.telemetry_enabled(Config.location)
|
108
|
+
UI::Terminal.output ""
|
109
|
+
end
|
110
|
+
|
111
|
+
def start_telemeter_upload
|
112
|
+
ChefApply::Telemeter::Sender.start_upload_thread()
|
113
|
+
end
|
114
|
+
|
115
|
+
def setup_workstation_user_directories
|
116
|
+
# Note that none of these paths are customizable in config, so
|
117
|
+
# it's safe to do before we load config.
|
118
|
+
FileUtils.mkdir_p(Config::WS_BASE_PATH)
|
119
|
+
FileUtils.mkdir_p(Config.base_log_directory)
|
120
|
+
FileUtils.mkdir_p(Config.telemetry_path)
|
121
|
+
end
|
122
|
+
|
123
|
+
def load_config
|
124
|
+
path = custom_config_path
|
125
|
+
Config.custom_location(path) unless path.nil?
|
126
|
+
Config.load
|
127
|
+
end
|
128
|
+
|
129
|
+
# Look for a user-supplied config path by manually parsing the option.
|
130
|
+
# Note that we can't use Mixlib::CLI for this.
|
131
|
+
# To ensure that ChefApply::CLI initializes with correct
|
132
|
+
# option defaults, we need to have configuraton loaded before initializing it.
|
133
|
+
def custom_config_path
|
134
|
+
argv.each_with_index do |arg, index|
|
135
|
+
if arg == "--config-path" || arg == "-c"
|
136
|
+
next_arg = argv[index + 1]
|
137
|
+
raise ConfigPathNotProvided.new if next_arg.nil?
|
138
|
+
raise ConfigPathInvalid.new(next_arg) unless File.file?(next_arg) && File.readable?(next_arg)
|
139
|
+
return next_arg
|
140
|
+
end
|
141
|
+
end
|
142
|
+
nil
|
143
|
+
end
|
144
|
+
|
145
|
+
def setup_logging
|
146
|
+
ChefApply::Log.setup(Config.log.location, Config.log.level.to_sym)
|
147
|
+
ChefApply::Log.info("Initialized logger")
|
148
|
+
end
|
149
|
+
|
150
|
+
def start_chef_apply
|
151
|
+
require "chef_apply/cli"
|
152
|
+
ChefApply::CLI.new(@argv).run
|
153
|
+
end
|
154
|
+
class ConfigPathNotProvided < StandardError; end
|
155
|
+
class ConfigPathInvalid < StandardError
|
156
|
+
attr_reader :path
|
157
|
+
def initialize(path)
|
158
|
+
@path = path
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
#
|
2
|
+
# Copyright:: Copyright (c) 2017 Chef Software Inc.
|
3
|
+
# License:: Apache License, Version 2.0
|
4
|
+
#
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
6
|
+
# you may not use this file except in compliance with the License.
|
7
|
+
# You may obtain a copy of the License at
|
8
|
+
#
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
10
|
+
#
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
14
|
+
# See the License for the specific language governing permissions and
|
15
|
+
# limitations under the License.
|
16
|
+
#
|
17
|
+
|
18
|
+
module ChefApply
|
19
|
+
class StatusReporter
|
20
|
+
|
21
|
+
def initialize(ui_element, prefix: nil, key: nil)
|
22
|
+
@ui_element = ui_element
|
23
|
+
@key = key
|
24
|
+
@ui_element.update(prefix: prefix)
|
25
|
+
end
|
26
|
+
|
27
|
+
def update(msg)
|
28
|
+
@ui_element.update({ @key => msg })
|
29
|
+
end
|
30
|
+
|
31
|
+
def success(msg)
|
32
|
+
update(msg)
|
33
|
+
@ui_element.success
|
34
|
+
end
|
35
|
+
|
36
|
+
def error(msg)
|
37
|
+
update(msg)
|
38
|
+
@ui_element.error
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,233 @@
|
|
1
|
+
#
|
2
|
+
# Copyright:: Copyright (c) 2017 Chef Software Inc.
|
3
|
+
# License:: Apache License, Version 2.0
|
4
|
+
#
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
6
|
+
# you may not use this file except in compliance with the License.
|
7
|
+
# You may obtain a copy of the License at
|
8
|
+
#
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
10
|
+
#
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
14
|
+
# See the License for the specific language governing permissions and
|
15
|
+
# limitations under the License.
|
16
|
+
#
|
17
|
+
|
18
|
+
require "chef_apply/log"
|
19
|
+
require "chef_apply/error"
|
20
|
+
require "train"
|
21
|
+
module ChefApply
|
22
|
+
class TargetHost
|
23
|
+
attr_reader :config, :reporter, :backend, :transport_type
|
24
|
+
# These values may exist in .ssh/config but will be ignored by train
|
25
|
+
# in favor of its defaults unless we specify them explicitly.
|
26
|
+
# See #apply_ssh_config
|
27
|
+
SSH_CONFIG_OVERRIDE_KEYS = [:user, :port, :proxy]
|
28
|
+
|
29
|
+
def self.instance_for_url(target, opts = {})
|
30
|
+
opts = { target: @url }
|
31
|
+
target_host = new(target, opts)
|
32
|
+
target_host.connect!
|
33
|
+
target_host
|
34
|
+
end
|
35
|
+
|
36
|
+
def initialize(host_url, opts = {}, logger = nil)
|
37
|
+
@config = connection_config(host_url, opts, logger)
|
38
|
+
@transport_type = Train.validate_backend(@config)
|
39
|
+
apply_ssh_config(@config, opts) if @transport_type == "ssh"
|
40
|
+
@train_connection = Train.create(@transport_type, config)
|
41
|
+
end
|
42
|
+
|
43
|
+
def connection_config(host_url, opts_in, logger)
|
44
|
+
connection_opts = { target: host_url,
|
45
|
+
sudo: opts_in[:sudo] === false ? false : true,
|
46
|
+
www_form_encoded_password: true,
|
47
|
+
key_files: opts_in[:identity_file],
|
48
|
+
logger: ChefApply::Log }
|
49
|
+
if opts_in.has_key? :ssl
|
50
|
+
connection_opts[:ssl] = opts_in[:ssl]
|
51
|
+
connection_opts[:self_signed] = (opts_in[:ssl_verify] === false ? true : false)
|
52
|
+
end
|
53
|
+
|
54
|
+
[:sudo_password, :sudo, :sudo_command, :password, :user].each do |key|
|
55
|
+
connection_opts[key] = opts_in[key] if opts_in.has_key? key
|
56
|
+
end
|
57
|
+
|
58
|
+
Train.target_config(connection_opts)
|
59
|
+
end
|
60
|
+
|
61
|
+
def apply_ssh_config(config, opts_in)
|
62
|
+
# If we don't provide certain options, they will be defaulted
|
63
|
+
# within train - in the case of ssh, this will prevent the .ssh/config
|
64
|
+
# values from being picked up.
|
65
|
+
# Here we'll modify the returned @config to specify
|
66
|
+
# values that we get out of .ssh/config if present and if they haven't
|
67
|
+
# been explicitly given.
|
68
|
+
host_cfg = ssh_config_for_host(config[:host])
|
69
|
+
SSH_CONFIG_OVERRIDE_KEYS.each do |key|
|
70
|
+
if host_cfg.has_key?(key) && opts_in[key].nil?
|
71
|
+
config[key] = host_cfg[key]
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def connect!
|
77
|
+
return unless @backend.nil?
|
78
|
+
@backend = train_connection.connection
|
79
|
+
@backend.wait_until_ready
|
80
|
+
rescue Train::UserError => e
|
81
|
+
raise ConnectionFailure.new(e, config)
|
82
|
+
rescue Train::Error => e
|
83
|
+
# These are typically wrapper errors for other problems,
|
84
|
+
# so we'll prefer to use e.cause over e if available.
|
85
|
+
raise ConnectionFailure.new(e.cause || e, config)
|
86
|
+
end
|
87
|
+
|
88
|
+
# Returns the user being used to connect. Defaults to train's default user if not specified
|
89
|
+
# defaulted in .ssh/config (for ssh connections), as set up in '#apply_ssh_config'.
|
90
|
+
def user
|
91
|
+
return config[:user] unless config[:user].nil?
|
92
|
+
require "train/transports/ssh"
|
93
|
+
Train::Transports::SSH.default_options[:user][:default]
|
94
|
+
end
|
95
|
+
|
96
|
+
def hostname
|
97
|
+
config[:host]
|
98
|
+
end
|
99
|
+
|
100
|
+
def architecture
|
101
|
+
platform.arch
|
102
|
+
end
|
103
|
+
|
104
|
+
def version
|
105
|
+
platform.release
|
106
|
+
end
|
107
|
+
|
108
|
+
def base_os
|
109
|
+
if platform.family == "windows"
|
110
|
+
:windows
|
111
|
+
elsif platform.linux?
|
112
|
+
:linux
|
113
|
+
else
|
114
|
+
# TODO - this seems like it shouldn't happen here, when
|
115
|
+
# all the caller is doing is asking about the OS
|
116
|
+
raise ChefApply::TargetHost::UnsupportedTargetOS.new(platform.name)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
def platform
|
121
|
+
backend.platform
|
122
|
+
end
|
123
|
+
|
124
|
+
def run_command!(command, sudo_as_user = false)
|
125
|
+
result = run_command(command, sudo_as_user)
|
126
|
+
if result.exit_status != 0
|
127
|
+
raise RemoteExecutionFailed.new(@config[:host], command, result)
|
128
|
+
end
|
129
|
+
result
|
130
|
+
end
|
131
|
+
|
132
|
+
def run_command(command, sudo_as_user = false)
|
133
|
+
if config[:sudo] && sudo_as_user && base_os == :linux
|
134
|
+
command = "-u #{config[:user]} #{command}"
|
135
|
+
end
|
136
|
+
backend.run_command command
|
137
|
+
end
|
138
|
+
|
139
|
+
def upload_file(local_path, remote_path)
|
140
|
+
backend.upload(local_path, remote_path)
|
141
|
+
end
|
142
|
+
|
143
|
+
# Returns the installed chef version as a Gem::Version,
|
144
|
+
# or raised ChefNotInstalled if chef client version manifest can't
|
145
|
+
# be found.
|
146
|
+
def installed_chef_version
|
147
|
+
return @installed_chef_version if @installed_chef_version
|
148
|
+
# Note: In the case of a very old version of chef (that has no manifest - pre 12.0?)
|
149
|
+
# this will report as not installed.
|
150
|
+
manifest = get_chef_version_manifest()
|
151
|
+
raise ChefNotInstalled.new if manifest == :not_found
|
152
|
+
# We'll split the version here because unstable builds (where we currently
|
153
|
+
# install from) are in the form "Major.Minor.Build+HASH" which is not a valid
|
154
|
+
# version string.
|
155
|
+
@installed_chef_version = Gem::Version.new(manifest["build_version"].split("+")[0])
|
156
|
+
end
|
157
|
+
|
158
|
+
MANIFEST_PATHS = {
|
159
|
+
# TODO - use a proper method to query the win installation path -
|
160
|
+
# currently we're assuming the default, but this can be customized
|
161
|
+
# at install time.
|
162
|
+
# A working approach is below - but it runs very slowly in testing
|
163
|
+
# on a virtualbox windows vm:
|
164
|
+
# (over winrm) Get-WmiObject Win32_Product | Where {$_.Name -match 'Chef Client'}
|
165
|
+
windows: "c:\\opscode\\chef\\version-manifest.json",
|
166
|
+
linux: "/opt/chef/version-manifest.json"
|
167
|
+
}
|
168
|
+
|
169
|
+
def get_chef_version_manifest
|
170
|
+
path = MANIFEST_PATHS[base_os()]
|
171
|
+
manifest = backend.file(path)
|
172
|
+
return :not_found unless manifest.file?
|
173
|
+
JSON.parse(manifest.content)
|
174
|
+
end
|
175
|
+
|
176
|
+
private
|
177
|
+
|
178
|
+
def train_connection
|
179
|
+
@train_connection
|
180
|
+
end
|
181
|
+
|
182
|
+
def ssh_config_for_host(host)
|
183
|
+
require "net/ssh"
|
184
|
+
Net::SSH::Config.for(host)
|
185
|
+
end
|
186
|
+
|
187
|
+
class RemoteExecutionFailed < ChefApply::ErrorNoLogs
|
188
|
+
attr_reader :stdout, :stderr
|
189
|
+
def initialize(host, command, result)
|
190
|
+
super("CHEFRMT001",
|
191
|
+
command,
|
192
|
+
result.exit_status,
|
193
|
+
host,
|
194
|
+
result.stderr.empty? ? result.stdout : result.stderr)
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
class ConnectionFailure < ChefApply::ErrorNoLogs
|
199
|
+
# TODO: Currently this only handles sudo-related errors;
|
200
|
+
# we should also look at e.cause for underlying connection errors
|
201
|
+
# which are presently only visible in log files.
|
202
|
+
def initialize(original_exception, connection_opts)
|
203
|
+
sudo_command = connection_opts[:sudo_command]
|
204
|
+
init_params =
|
205
|
+
# Comments below show the original_exception.reason values to check for instead of strings,
|
206
|
+
# after train 1.4.12 is consumable.
|
207
|
+
case original_exception.message # original_exception.reason
|
208
|
+
when /Sudo requires a password/ # :sudo_password_required
|
209
|
+
"CHEFTRN003"
|
210
|
+
when /Wrong sudo password/ #:bad_sudo_password
|
211
|
+
"CHEFTRN004"
|
212
|
+
when /Can't find sudo command/, /No such file/, /command not found/ # :sudo_command_not_found
|
213
|
+
# NOTE: In the /No such file/ case, reason will be nil - we still have
|
214
|
+
# to check message text. (Or PR to train to handle this case)
|
215
|
+
["CHEFTRN005", sudo_command] # :sudo_command_not_found
|
216
|
+
when /Sudo requires a TTY.*/ # :sudo_no_tty
|
217
|
+
"CHEFTRN006"
|
218
|
+
when /has no keys added/
|
219
|
+
"CHEFTRN007"
|
220
|
+
else
|
221
|
+
["CHEFTRN999", original_exception.message]
|
222
|
+
end
|
223
|
+
super(*(Array(init_params).flatten))
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
class ChefNotInstalled < StandardError; end
|
228
|
+
|
229
|
+
class UnsupportedTargetOS < ChefApply::ErrorNoLogs
|
230
|
+
def initialize(os_name); super("CHEFTARG001", os_name); end
|
231
|
+
end
|
232
|
+
end
|
233
|
+
end
|