chef-core 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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 ChefCore
21
+ class Log
22
+ extend Mixlib::Log
23
+
24
+ def self.setup(location, log_level)
25
+ if location.is_a?(String)
26
+ if location.casecmp("stdout") == 0
27
+ location = $stdout
28
+ else
29
+ location = File.open(location, "w+")
30
+ end
31
+ end
32
+ @location = location
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,63 @@
1
+
2
+
3
+ module ChefCore
4
+ class TargetHost
5
+ module Linux
6
+ def omnibus_manifest_path
7
+ # TODO - if habitat install on target, this won't work
8
+ # Note that we can't use File::Join, because that will render for the
9
+ # CURRENT platform - not the platform of the target.
10
+ "/opt/chef/version-manifest.json"
11
+ end
12
+
13
+ def mkdir(path)
14
+ run_command!("mkdir -p #{path}")
15
+ end
16
+
17
+ def chown(path, owner)
18
+ owner ||= user()
19
+ run_command!("chown #{owner} '#{path}'")
20
+ nil
21
+ end
22
+
23
+ def make_temp_dir
24
+ # We will cache this so that we only
25
+ @tempdir ||= begin
26
+ res = run_command!("bash -c '#{MKTEMP_COMMAND}'")
27
+ res.stdout.chomp.strip
28
+ end
29
+ end
30
+
31
+ def install_package(target_package_path)
32
+ install_cmd = case File.extname(target_package_path)
33
+ when ".rpm"
34
+ "rpm -Uvh #{target_package_path}"
35
+ when ".deb"
36
+ "dpkg -i #{target_package_path}"
37
+ end
38
+ run_command!(install_cmd)
39
+ nil
40
+ end
41
+
42
+ def del_file(path)
43
+ run_command!("rm -rf #{path}")
44
+ end
45
+
46
+ def del_dir(path)
47
+ del_file(path)
48
+ end
49
+
50
+ def ws_cache_path
51
+ "/var/chef-workstation"
52
+ end
53
+
54
+ # Nothing to escape in a linux-based path
55
+ def normalize_path(path)
56
+ path
57
+ end
58
+
59
+ MKTEMP_COMMAND = "d=$(mktemp -d -p${TMPDIR:-/tmp} chef_XXXXXX); echo $d".freeze
60
+
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,62 @@
1
+
2
+ module ChefCore
3
+ class TargetHost
4
+ module Windows
5
+ def omnibus_manifest_path
6
+ # TODO - use a proper method to query the win installation path -
7
+ # currently we're assuming the default, but this can be customized
8
+ # at install time.
9
+ # A working approach is below - but it runs very slowly (~10s) in testing
10
+ # on a virtualbox windows vm:
11
+ # (over winrm) Get-WmiObject Win32_Product | Where {$_.Name -match 'Chef Client'}
12
+ # TODO - if habitat install on target, this won't work
13
+ "c:\\opscode\\chef\\version-manifest.json"
14
+ end
15
+
16
+ def mkdir(path)
17
+ run_command!("New-Item -ItemType Directory -Force -Path #{path}")
18
+ end
19
+
20
+ def chown(path, owner)
21
+ # This implementation left intentionally blank.
22
+ # To date, we have not needed chown functionality on windows;
23
+ # when/if that changes we'll need to implement it here.
24
+ nil
25
+ end
26
+
27
+ def make_temp_dir
28
+ @tmpdir ||= begin
29
+ res = run_command!(MKTEMP_COMMAND)
30
+ res.stdout.chomp.strip
31
+ end
32
+ end
33
+
34
+ def install_package(target_package_path)
35
+ # While powershell does not mind the mixed path separators \ and /,
36
+ # 'cmd.exe' definitely does - so we'll make the path cmd-friendly
37
+ # before running the command
38
+ cmd = "cmd /c msiexec /package #{target_package_path.tr("/", "\\")} /quiet"
39
+ run_command!(cmd)
40
+ nil
41
+ end
42
+
43
+ def del_file(path)
44
+ run_command!("If (Test-Path #{path}) { Remove-Item -Force -Path #{path} }")
45
+ end
46
+
47
+ def del_dir(path)
48
+ run_command!("Remove-Item -Recurse -Force –Path #{path}")
49
+ end
50
+
51
+ def ws_cache_path
52
+ '#{ENV[\'APPDATA\']}/chef-workstation'
53
+ end
54
+
55
+ MKTEMP_COMMAND = "$parent = [System.IO.Path]::GetTempPath();" +
56
+ "[string] $name = [System.Guid]::NewGuid();" +
57
+ "$tmp = New-Item -ItemType Directory -Path " +
58
+ "(Join-Path $parent $name);" +
59
+ "$tmp.FullName".freeze
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,351 @@
1
+ #
2
+ # Copyright:: Copyright (c) 2017-2019 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_core/log"
19
+ require "chef_core/error"
20
+ require "train"
21
+
22
+ module ChefCore
23
+ class TargetHost
24
+ attr_reader :config, :reporter, :backend, :transport_type
25
+ # These values may exist in .ssh/config but will be ignored by train
26
+ # in favor of its defaults unless we specify them explicitly.
27
+ # See #apply_ssh_config
28
+ SSH_CONFIG_OVERRIDE_KEYS = [:user, :port, :proxy].freeze
29
+
30
+ # We're borrowing a page from train here - because setting up a
31
+ # reliable connection for testing is a multi-step process,
32
+ # we'll provide this method which instantiates a TargetHost connected
33
+ # to a train mock backend. If the family/name provided resolves to a suported
34
+ # OS, this instance will mix-in the supporting methods for the given platform;
35
+ # otherwise those methods will raise NotImplementedError.
36
+ def self.mock_instance(url, family: "unknown", name: "unknown",
37
+ release: "unknown", arch: "x86_64")
38
+ # Specifying sudo: false ensures that attempted operations
39
+ # don't fail because the mock platform doesn't support sudo
40
+ target_host = TargetHost.new(url, { sudo: false })
41
+
42
+ # Don't pull in the platform-specific mixins automatically during connect
43
+ # Otherwise, it will raise since it can't resolve the OS without the mock.
44
+ target_host.instance_variable_set(:@mocked_connection, true)
45
+ target_host.connect!
46
+
47
+ # We need to provide this mock before invoking mix_in_target_platform,
48
+ # otherwise it will fail with an unknown OS (since we don't have a real connection).
49
+ target_host.backend.mock_os(
50
+ family: family,
51
+ name: name,
52
+ release: release,
53
+ arch: arch
54
+ )
55
+
56
+ # Only mix-in if we can identify the platform. This
57
+ # prevents mix_in_target_platform! from raising on unknown platform during
58
+ # tests that validate unsupported platform behaviors.
59
+ if target_host.base_os != :other
60
+ target_host.mix_in_target_platform!
61
+ end
62
+
63
+ target_host
64
+ end
65
+
66
+ def initialize(host_url, opts = {}, logger = nil)
67
+ @config = connection_config(host_url, opts, logger)
68
+ @transport_type = Train.validate_backend(@config)
69
+ apply_ssh_config(@config, opts) if @transport_type == "ssh"
70
+ @train_connection = Train.create(@transport_type, config)
71
+ end
72
+
73
+ def connection_config(host_url, opts_in, logger)
74
+ connection_opts = { target: host_url,
75
+ sudo: opts_in[:sudo] === false ? false : true,
76
+ www_form_encoded_password: true,
77
+ key_files: opts_in[:identity_file] || opts_in[:key_files],
78
+ non_interactive: true, # Prevent password prompts
79
+ connection_retries: 2,
80
+ connection_retry_sleep: 1,
81
+ logger: opts_in[:logger] || ChefCore::Log }
82
+
83
+ target_opts = Train.unpack_target_from_uri(host_url)
84
+ if opts_in.key?(:ssl) && opts_in[:ssl]
85
+ connection_opts[:ssl] = opts_in[:ssl]
86
+ connection_opts[:self_signed] = opts_in[:self_signed] || (opts_in[:ssl_verify] === false ? true : false)
87
+ end
88
+
89
+ target_opts[:host] = host_url if target_opts[:host].nil?
90
+ target_opts[:backend] = "ssh" if target_opts[:backend].nil?
91
+ connection_opts = connection_opts.merge(target_opts)
92
+
93
+ # From WinRM gem: It is recommended that you :disable_sspi => true if you are using the plaintext or ssl transport.
94
+ # See note here: https://github.com/mwrock/WinRM#example
95
+ if ["ssl", "plaintext"].include?(target_opts[:winrm_transport])
96
+ target_opts[:winrm_disable_sspi] = true
97
+ end
98
+
99
+ connection_opts = connection_opts.merge(target_opts)
100
+
101
+ # Anything we haven't explicitly set already, pass through to train.
102
+ Train.options(target_opts[:backend]).keys.each do |key|
103
+ if opts_in.key?(key) && !connection_opts.key?(key)
104
+ connection_opts[key] = opts_in[key]
105
+ end
106
+ end
107
+
108
+ Train.target_config(connection_opts)
109
+ end
110
+
111
+ def apply_ssh_config(config, opts_in)
112
+ # If we don't provide certain options, they will be defaulted
113
+ # within train - in the case of ssh, this will prevent the .ssh/config
114
+ # values from being picked up.
115
+ # Here we'll modify the returned @config to specify
116
+ # values that we get out of .ssh/config if present and if they haven't
117
+ # been explicitly given.
118
+ host_cfg = ssh_config_for_host(config[:host])
119
+ SSH_CONFIG_OVERRIDE_KEYS.each do |key|
120
+ if host_cfg.key?(key) && opts_in[key].nil?
121
+ config[key] = host_cfg[key]
122
+ end
123
+ end
124
+ end
125
+
126
+ # Establish connection to configured target.
127
+ #
128
+ def connect!
129
+ # Keep existing connections
130
+ return unless @backend.nil?
131
+ @backend = train_connection.connection
132
+ @backend.wait_until_ready
133
+
134
+ # When the testing function `mock_instance` is used, it will set
135
+ # this instance variable to false and handle this function call
136
+ # of mixin functions based on the mocked platform.
137
+ mix_in_target_platform! unless @mocked_connection
138
+ rescue Train::UserError => e
139
+ raise ConnectionFailure.new(e, config)
140
+ rescue Train::Error => e
141
+ # These are typically wrapper errors for other problems,
142
+ # so we'll prefer to use e.cause over e if available.
143
+ raise ConnectionFailure.new(e.cause || e, config)
144
+ end
145
+
146
+ def mix_in_target_platform!
147
+ case base_os
148
+ when :linux
149
+ require "chef_core/target_host/linux"
150
+ class << self; include ChefCore::TargetHost::Linux; end
151
+ when :windows
152
+ require "chef_core/target_host/windows"
153
+ class << self; include ChefCore::TargetHost::Windows; end
154
+ when :other
155
+ raise ChefCore::TargetHost::UnsupportedTargetOS.new(platform.name)
156
+ end
157
+ end
158
+
159
+ # Returns the user being used to connect. Defaults to train's default user if not specified
160
+ def user
161
+ return config[:user] unless config[:user].nil?
162
+ require "train/transports/ssh"
163
+ # TODO - this should use the right transport, not default to SSH
164
+ Train::Transports::SSH.default_options[:user][:default]
165
+ end
166
+
167
+ def hostname
168
+ config[:host]
169
+ end
170
+
171
+ def architecture
172
+ platform.arch
173
+ end
174
+
175
+ def version
176
+ platform.release
177
+ end
178
+
179
+ def base_os
180
+ if platform.windows?
181
+ :windows
182
+ elsif platform.linux?
183
+ :linux
184
+ else
185
+ :other
186
+ end
187
+ end
188
+
189
+ # TODO 2019-01-29 not expose this, it's internal implemenation. Same with #backend.
190
+ def platform
191
+ backend.platform
192
+ end
193
+
194
+ def run_command!(command, &data_handler)
195
+ result = run_command(command, &data_handler)
196
+ if result.exit_status != 0
197
+ raise RemoteExecutionFailed.new(@config[:host], command, result)
198
+ end
199
+ result
200
+ end
201
+
202
+ def run_command(command, &data_handler)
203
+ backend.run_command command, &data_handler
204
+ end
205
+
206
+ # TODO spec
207
+ def save_as_remote_file(content, remote_path)
208
+ t = Tempfile.new("chef-content")
209
+ t << content
210
+ t.close
211
+ upload_file(t.path, remote_path)
212
+ ensure
213
+ t.close
214
+ t.unlink
215
+ end
216
+
217
+ def upload_file(local_path, remote_path)
218
+ backend.upload(local_path, remote_path)
219
+ end
220
+
221
+ # Retrieve the contents of a remote file. Returns nil
222
+ # if the file didn't exist or couldn't be read.
223
+ def fetch_file_contents(remote_path)
224
+ result = backend.file(remote_path)
225
+ if result.exist? && result.file?
226
+ result.content
227
+ else
228
+ nil
229
+ end
230
+ end
231
+
232
+ # Returns the installed chef version as a Gem::Version,
233
+ # or raised ChefNotInstalled if chef client version manifest can't
234
+ # be found.
235
+ def installed_chef_version
236
+ return @installed_chef_version if @installed_chef_version
237
+ # Note: In the case of a very old version of chef (that has no manifest - pre 12.0?)
238
+ # this will report as not installed.
239
+ manifest = read_chef_version_manifest()
240
+
241
+ # We split the version here because unstable builds install from)
242
+ # are in the form "Major.Minor.Build+HASH" which is not a valid
243
+ # version string.
244
+ @installed_chef_version = Gem::Version.new(manifest["build_version"].split("+")[0])
245
+ end
246
+
247
+ def read_chef_version_manifest
248
+ manifest = fetch_file_contents(omnibus_manifest_path)
249
+ raise ChefNotInstalled.new if manifest.nil?
250
+ JSON.parse(manifest)
251
+ end
252
+
253
+ # Creates and caches location of temporary directory on the remote host
254
+ # using platform-specific implementations of make_temp_dir
255
+ # This will also set ownership to the connecting user instead of default of
256
+ # root when sudo'd, so that the dir can be used to upload files using scp
257
+ # as the connecting user.
258
+ #
259
+ # The base temp dir is cached and will only be created once per connection lifetime.
260
+ def temp_dir
261
+ dir = make_temp_dir()
262
+ chown(dir, user)
263
+ dir
264
+ end
265
+
266
+ # create a directory. because we run all commands as root, this will also set group:owner
267
+ # to the connecting user if host isn't windows so that scp -- which uses the connecting user --
268
+ # will have permissions to upload into it.
269
+ def make_directory(path)
270
+ mkdir(path)
271
+ chown(path, user)
272
+ path
273
+ end
274
+
275
+ # normalizes path across OS's
276
+ def normalize_path(p) # NOTE BOOTSTRAP: was action::base::escape_windows_path
277
+ p.tr("\\", "/")
278
+ end
279
+
280
+ # Simplified chown - just sets user, defaults to connection user. Does not touch
281
+ # group. Only has effect on non-windows targets
282
+ def chown(path, owner); raise NotImplementedError; end
283
+
284
+ # Platform-specific installation of packages
285
+ def install_package(target_package_path); raise NotImplementedError; end
286
+
287
+ def ws_cache_path; raise NotImplementedError; end
288
+
289
+ # Recursively delete directory
290
+ def del_dir(path); raise NotImplementedError; end
291
+
292
+ def del_file(path); raise NotImplementedError; end
293
+
294
+ def omnibus_manifest_path(); raise NotImplementedError; end
295
+
296
+ private
297
+
298
+ def train_connection
299
+ @train_connection
300
+ end
301
+
302
+ def ssh_config_for_host(host)
303
+ require "net/ssh"
304
+ Net::SSH::Config.for(host)
305
+ end
306
+
307
+ class RemoteExecutionFailed < ChefCore::Error
308
+ attr_reader :stdout, :stderr
309
+ def initialize(host, command, result)
310
+ super("CHEFRMT001",
311
+ command,
312
+ result.exit_status,
313
+ host,
314
+ result.stderr.empty? ? result.stdout : result.stderr)
315
+ end
316
+ end
317
+
318
+ class ConnectionFailure < ChefCore::Error
319
+ # TODO: Currently this only handles sudo-related errors;
320
+ # we should also look at e.cause for underlying connection errors
321
+ # which are presently only visible in log files.
322
+ def initialize(original_exception, connection_opts)
323
+ init_params =
324
+ # Comments below show the original_exception.reason values to check for instead of strings,
325
+ # after train 1.4.12 is consumable.
326
+ case original_exception.message # original_exception.reason
327
+ when /Sudo requires a password/ # :sudo_password_required
328
+ "CHEFTRN003"
329
+ when /Wrong sudo password/ #:bad_sudo_password
330
+ "CHEFTRN004"
331
+ when /Can't find sudo command/, /No such file/, /command not found/ # :sudo_command_not_found
332
+ # NOTE: In the /No such file/ case, reason will be nil - we still have
333
+ # to check message text. (Or PR to train to handle this case)
334
+ sudo_command = connection_opts[:sudo_command]
335
+ ["CHEFTRN005", sudo_command] # :sudo_command_not_found
336
+ when /Sudo requires a TTY.*/ # :sudo_no_tty
337
+ "CHEFTRN006"
338
+ when /has no keys added/
339
+ "CHEFTRN007"
340
+ else
341
+ ["CHEFTRN999", original_exception.message]
342
+ end
343
+ super(*(Array(init_params).flatten))
344
+ end
345
+ end
346
+ class ChefNotInstalled < StandardError; end
347
+ class UnsupportedTargetOS < ChefCore::Error
348
+ def initialize(os_name); super("CHEFTARG001", os_name); end
349
+ end
350
+ end
351
+ end