-
Notifications
You must be signed in to change notification settings - Fork 14
/
cli.rb
346 lines (321 loc) · 12.3 KB
/
cli.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
#
# p
# Copyright:: Copyright (c) 2018 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 "mixlib/cli"
require "chef_core/error"
require "chef_core/log"
require "chef_core/target_host"
require "chef_core/target_resolver"
require "chef_core/telemeter"
require "chef_apply/config"
require "chef-config/config"
require "chef-config/logger"
require "chef_apply/cli/validation"
require "chef_apply/cli/options"
require "chef_apply/cli/help"
require "chef_core/cliux/ui/error_printer"
require "chef_core/cliux/ui/terminal"
require "chef_core/cliux/ui/terminal/job"
require "chef_core/actions/converge_target"
require "chef_apply/actions/generate_temp_cookbook"
require "chef_apply/actions/generate_local_policy"
module ChefApply
class CLI
UI = ChefCore::CLIUX::UI
attr_reader :temp_cookbook, :archive_file_location, :target_hosts
include Mixlib::CLI
# Pulls in the CLI options and flags we have defined for this command.
include ChefApply::CLI::Options
# Argument validation and parsing behaviors
include ChefApply::CLI::Validation
# Help and version formatting
include ChefApply::CLI::Help
RC_OK = 0
RC_COMMAND_FAILED = 1
RC_UNHANDLED_ERROR = 32
RC_ERROR_HANDLING_FAILED = 64
def initialize(argv)
@argv = argv.clone
@rc = RC_OK
super()
end
def run
# Perform a timing and capture of the run. Individual methods and actions may perform
# nested Telemeter.timed_*_capture or Telemeter.capture calls in their operation, and
# they will be captured in the same telemetry session.
# NOTE: We're not currently sending arguments to telemetry because we have not implemented
# pre-parsing of arguments to eliminate potentially sensitive data such as
# passwords in host name, or in ad-hoc converge properties.
ChefCore::Telemeter.timed_run_capture([:redacted]) do
begin
perform_run
rescue Exception => e
@rc = handle_run_error(e)
end
end
rescue => e
@rc = handle_run_error(e)
ensure
ChefCore::Telemeter.commit
exit @rc
end
def handle_run_error(e)
case e
when nil
RC_OK
when ChefCore::WrappedError
UI::ErrorPrinter.show_error(e, error_config)
RC_COMMAND_FAILED
when SystemExit
e.status
when Exception
UI::ErrorPrinter.dump_unexpected_error(e)
RC_ERROR_HANDLING_FAILED
else
UI::ErrorPrinter.dump_unexpected_error(e)
RC_UNHANDLED_ERROR
end
end
def perform_run
parse_options(@argv)
if @argv.empty? || parsed_options[:help]
show_help
elsif parsed_options[:version]
show_version
else
validate_params(cli_arguments)
target_hosts = resolve_targets(cli_arguments.shift, parsed_options)
render_cookbook_setup(cli_arguments)
render_converge(target_hosts)
end
rescue OptionParser::InvalidOption => e # from parse_options
# Using nil here is a bit gross but it prevents usage from printing.
ove = OptionValidationError.new("CHEFVAL010", nil,
e.message.split(":")[1].strip, # only want the flag
format_flags.lines[1..-1].join # remove 'FLAGS:' header
)
handle_perform_error(ove)
rescue => e
handle_perform_error(e)
ensure
temp_cookbook.delete unless temp_cookbook.nil?
end
def resolve_targets(host_spec, opts)
@target_hosts = ChefCore::TargetResolver.new(host_spec,
opts.delete(:protocol),
opts).targets
end
def render_cookbook_setup(arguments)
# TODO update Job so that it doesn't require prefix and host. As a data container,
# should these attributes even be required?
job = UI::Terminal::Job.new("", nil) do |reporter|
@temp_cookbook = generate_temp_cookbook(arguments, reporter)
end
UI::Terminal.render_job("...", job)
handle_failed_job(job)
job = UI::Terminal::Job.new("", nil) do |reporter|
@archive_file_location = generate_local_policy(reporter)
end
UI::Terminal.render_job("...", job)
handle_failed_job(job)
end
def render_converge(target_hosts)
jobs = target_hosts.map do |target_host|
# Each block will run in its own thread during render.
UI::Terminal::Job.new("[#{target_host.hostname}]", target_host) do |reporter|
connect_target(target_host, reporter)
install(target_host, reporter)
converge(reporter, archive_file_location, target_host)
end
end
header = TS.converge.header(target_hosts.length, temp_cookbook.descriptor, temp_cookbook.from)
UI::Terminal.render_parallel_jobs(header, jobs)
handle_failed_jobs(jobs)
end
# Accepts a target_host and establishes the connection to that host
# while providing visual feedback via the Terminal API.
def connect_target(target_host, reporter)
connect_message = T.status.connecting(target_host.user)
reporter.update(connect_message)
do_connect(target_host, reporter)
end
def install(target_host, reporter)
require "chef_core/actions/install_chef"
context = TS.install_chef
reporter.update(context.verifying)
installer = ChefCore::Actions::InstallChef.new(target_host: target_host,
check_only: !parsed_options[:install],
cache_path: Config.cache.path )
installer.run do |event, data|
case event
when :installing
if installer.upgrading?
message = context.upgrading(target_host.installed_chef_version, installer.version_to_install)
else
message = context.installing(installer.version_to_install)
end
reporter.update(message)
when :uploading
reporter.update(context.uploading)
when :downloading
reporter.update(context.downloading)
when :already_installed
reporter.update(context.already_present(target_host.installed_chef_version))
when :install_complete
if installer.upgrading?
message = context.upgrade_success(target_host.installed_chef_version, installer.version_to_install)
else
message = context.install_success(installer.version_to_install)
end
reporter.update(message)
else
handle_message(event, data, reporter)
end
end
end
# Runs a GenerateCookbook action based on recipe/resource infoprovided
# and renders UI updates as the action reports back
def generate_temp_cookbook(arguments, reporter)
opts = if arguments.length == 1
{ recipe_spec: arguments.shift,
cookbook_repo_paths: parsed_options[:cookbook_repo_paths] }
else
{ resource_type: arguments.shift,
resource_name: arguments.shift,
resource_properties: properties_from_string(arguments) }
end
action = ChefApply::Actions::GenerateTempCookbook.from_options(opts)
action.run do |event, data|
case event
when :generating
reporter.update(TS.generate_temp_cookbook.generating)
when :success
reporter.success(TS.generate_temp_cookbook.success)
else
handle_message(event, data, reporter)
end
end
action.generated_cookbook
end
# Runs the GenerateLocalPolicy action and renders UI updates
# as the action reports back
def generate_local_policy(reporter)
action = ChefApply::Actions::GenerateLocalPolicy.new(cookbook: temp_cookbook)
action.run do |event, data|
case event
when :generating
reporter.update(TS.generate_local_policy.generating)
when :exporting
reporter.update(TS.generate_local_policy.exporting)
when :success
reporter.success(TS.generate_local_policy.success)
else
handle_message(event, data, reporter)
end
end
action.archive_file_location
end
# Runs the Converge action and renders UI updates as
# the action reports back
def converge(reporter, local_policy_path, target_host)
reporter.update(TS.converge.converging(temp_cookbook.descriptor))
converge_args = {
target_host: target_host,
local_policy_path: local_policy_path,
target_log_level: Config.log.target_level,
trusted_certs_dir: Config.chef.trusted_certs_dir,
data_collector_url: Config.data_collector.url,
data_collector_token: Config.data_collector.token,
}
converger = ChefCore::Actions::ConvergeTarget.new(converge_args)
converger.run do |event, data|
case event
when :success
reporter.success(TS.converge.success(temp_cookbook.descriptor))
when :converge_error
reporter.error(TS.converge.failure(temp_cookbook.descriptor))
when :creating_remote_policy
reporter.update(TS.converge.creating_remote_policy)
when :uploading_trusted_certs
reporter.update(TS.converge.uploading_trusted_certs)
when :running_chef
reporter.update(TS.converge.converging(temp_cookbook.descriptor))
when :reboot
reporter.success(TS.converge.reboot)
else
handle_message(event, data, reporter)
end
end
end
def handle_perform_error(e)
require "chef_core/errors/standard_error_resolver"
id = e.respond_to?(:id) ? e.id : e.class.to_s
# TODO: This was sending host information for certain ssh errors
# For now we'll just redact the whole message
# message = e.respond_to?(:message) ? e.message : e.to_s
ChefCore::Telemeter.capture(:error, exception: { id: id, message: "redacted" })
wrapper = ChefCore::Errors::StandardErrorResolver.wrap_exception(e)
capture_exception_backtrace(wrapper)
# Now that our housekeeping is done, allow user-facing handling/formatting
# in `run` to execute by re-raising
raise wrapper
end
# When running multiple jobs, exceptions are captured within the
# job to avoid interrupting other jobs in process. This function
# collects failures and raises directly (in the case of just one job in the list)
# or raises a MultiJobFailure (when more than one job was being run)
def handle_failed_jobs(jobs)
failed_jobs = jobs.select { |j| !j.exception.nil? }
return if failed_jobs.empty?
if jobs.length == 1
# Don't provide a bad UX by showing a 'one or more jobs has failed'
# message when there was only one job.
raise jobs.first.exception
end
raise ChefApply::MultiJobFailure.new(failed_jobs)
end
def handle_failed_job(job)
raise job.exception unless job.exception.nil?
end
# A handler for common action messages
def handle_message(message, data, reporter)
if message == :error # data[0] = exception
# Mark the current task as failed with whatever data is available to us
reporter.error(UI::ErrorPrinter.error_summary(data[0]))
end
end
def capture_exception_backtrace(e)
UI::ErrorPrinter.write_backtrace(e, @argv, error_config)
end
private
def do_connect(target_host, reporter)
target_host.connect!
reporter.update(T.status.connected)
rescue StandardError => e
message = UI::ErrorPrinter.error_summary(e)
reporter.error(message)
raise
end
def error_config
{
log_location: Config.log.location,
error_output_path: Config.error_output_path,
stack_trace_path: Config.stack_trace_path,
}
end
end
end