Class: ChefApply::CLI

Inherits:
Object
  • Object
show all
Includes:
CLIOptions, Mixlib::CLI
Defined in:
lib/chef_apply/cli.rb

Defined Under Namespace

Classes: OptionValidationError, PolicyfileInstallError

Constant Summary collapse

RC_OK =
0
RC_COMMAND_FAILED =
1
RC_UNHANDLED_ERROR =
32
RC_ERROR_HANDLING_FAILED =
64
PROPERTY_MATCHER =

The first param is always hostname. Then we either have

  1. A recipe designation

  2. A resource type and resource name followed by any properties

/^([a-zA-Z0-9_]+)=(.+)$/
CB_MATCHER =
'[\w\-]+'

Constants included from CLIOptions

ChefApply::CLIOptions::T, ChefApply::CLIOptions::TS

Instance Method Summary collapse

Methods included from CLIOptions

included, #parsed_options

Constructor Details

#initialize(argv) ⇒ CLI

Returns a new instance of CLI.



48
49
50
51
52
# File 'lib/chef_apply/cli.rb', line 48

def initialize(argv)
  @argv = argv.clone
  @rc = RC_OK
  super()
end

Instance Method Details

#capture_exception_backtrace(e) ⇒ Object



398
399
400
# File 'lib/chef_apply/cli.rb', line 398

def capture_exception_backtrace(e)
  UI::ErrorPrinter.write_backtrace(e, @argv)
end

#configure_chefObject

Now that we are leveraging Chef locally we want to perform some initial setup of it



207
208
209
210
211
212
213
# File 'lib/chef_apply/cli.rb', line 207

def configure_chef
  ChefConfig.logger = ChefApply::Log
  # Setting the config isn't enough, we need to ensure the logger is initialized
  # or automatic initialization will still go to stdout
  Chef::Log.init(ChefApply::Log)
  Chef::Log.level = ChefApply::Log.level
end

#connect_target(target_host, reporter = nil) ⇒ Object

Accepts a target_host and establishes the connection to that host while providing visual feedback via the Terminal API.



136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/chef_apply/cli.rb', line 136

def connect_target(target_host, reporter = nil)
  connect_message = T.status.connecting(target_host.user)
  if reporter.nil?
    UI::Terminal.render_job(connect_message, prefix: "[#{target_host.config[:host]}]") do |rep|
      do_connect(target_host, rep, :success)
    end
  else
    reporter.update(connect_message)
    do_connect(target_host, reporter, :update)
  end
  target_host
end

#converge(reporter, local_policy_path, target_host) ⇒ Object

Runs the Converge action and renders UI updates as the action reports back



341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
# File 'lib/chef_apply/cli.rb', line 341

def converge(reporter, local_policy_path, target_host)
  converge_args = { local_policy_path: local_policy_path, target_host: target_host }
  converger = Action::ConvergeTarget.new(converge_args)
  converger.run do |event, data|
    case event
    when :success
      reporter.success(TS.converge.success)
    when :converge_error
      reporter.error(TS.converge.failure)
    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.running_chef)
    when :reboot
      reporter.success(TS.converge.reboot)
    else
      handle_message(event, data, reporter)
    end
  end
end

#create_local_policy(local_cookbook) ⇒ Object



281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
# File 'lib/chef_apply/cli.rb', line 281

def create_local_policy(local_cookbook)
  require "chef-dk/ui"
  require "chef-dk/policyfile_services/export_repo"
  require "chef-dk/policyfile_services/install"
  policyfile_installer = ChefDK::PolicyfileServices::Install.new(
    ui: ChefDK::UI.null(),
    root_dir: local_cookbook.path
  )
  begin
    policyfile_installer.run
  rescue ChefDK::PolicyfileInstallError => e
    raise PolicyfileInstallError.new(e)
  end
  lock_path = File.join(local_cookbook.path, "Policyfile.lock.json")
  es = ChefDK::PolicyfileServices::ExportRepo.new(policyfile: lock_path,
                                                  root_dir: local_cookbook.path,
                                                  export_dir: File.join(local_cookbook.path, "export"),
                                                  archive: true,
                                                  force: true)
  es.run
  es.archive_file_location
end

#do_connect(target_host, reporter, update_method) ⇒ Object



406
407
408
409
410
411
412
413
# File 'lib/chef_apply/cli.rb', line 406

def do_connect(target_host, reporter, update_method)
  target_host.connect!
  reporter.send(update_method, T.status.connected)
rescue StandardError => e
  message = ChefApply::UI::ErrorPrinter.error_summary(e)
  reporter.error(message)
  raise
end

#format_flagsObject



421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
# File 'lib/chef_apply/cli.rb', line 421

def format_flags
  flag_text = "FLAGS:\n"
  justify_length = 0
  options.each_value do |spec|
    justify_length = [justify_length, spec[:long].length + 4].max
  end
  options.sort.to_h.each_value do |flag_spec|
    short = flag_spec[:short] || "  "
    short = short[0, 2] # We only want the flag portion, not the capture portion (if present)
    if short == "  "
      short = "    "
    else
      short = "#{short}, "
    end
    flags = "#{short}#{flag_spec[:long]}"
    flag_text << "    #{flags.ljust(justify_length)}    "
    ml_padding = " " * (justify_length + 8)
    first = true
    flag_spec[:description].split("\n").each do |d|
      flag_text << ml_padding unless first
      first = false
      flag_text << "#{d}\n"
    end
  end
  flag_text
end

#format_helpObject



415
416
417
418
419
# File 'lib/chef_apply/cli.rb', line 415

def format_help
  help_text = banner.clone # This prevents us appending to the banner text
  help_text << "\n"
  help_text << format_flags
end

#format_properties(string_props) ⇒ Object



215
216
217
218
219
220
221
222
223
# File 'lib/chef_apply/cli.rb', line 215

def format_properties(string_props)
  properties = {}
  string_props.each do |a|
    key, value = PROPERTY_MATCHER.match(a)[1..-1]
    value = transform_property_value(value)
    properties[key] = value
  end
  properties
end

#generate_temp_cookbook(cli_arguments) ⇒ Object

The user will either specify a single resource on the command line, or a recipe. We need to parse out those two different situations



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
# File 'lib/chef_apply/cli.rb', line 249

def generate_temp_cookbook(cli_arguments)
  temp_cookbook = TempCookbook.new
  if recipe_strategy?(cli_arguments)
    recipe_specifier = cli_arguments.shift
    ChefApply::Log.debug("Beginning to look for recipe specified as #{recipe_specifier}")
    if File.file?(recipe_specifier)
      ChefApply::Log.debug("#{recipe_specifier} is a valid path to a recipe")
      recipe_path = recipe_specifier
    else
      rl = RecipeLookup.new(parsed_options[:cookbook_repo_paths])
      cookbook_path_or_name, optional_recipe_name = rl.split(recipe_specifier)
      cookbook = rl.load_cookbook(cookbook_path_or_name)
      recipe_path = rl.find_recipe(cookbook, optional_recipe_name)
    end
    temp_cookbook.from_existing_recipe(recipe_path)
    initial_status_msg = TS.converge.converging_recipe(recipe_specifier)
  else
    resource_type = cli_arguments.shift
    resource_name = cli_arguments.shift
    temp_cookbook.from_resource(resource_type, resource_name, format_properties(cli_arguments))
    full_rs_name = "#{resource_type}[#{resource_name}]"
    ChefApply::Log.debug("Converging resource #{full_rs_name} on target")
    initial_status_msg = TS.converge.converging_resource(full_rs_name)
  end

  [temp_cookbook, initial_status_msg]
end

#handle_job_failures(jobs) ⇒ Object

When running multiple jobs, exceptions are captured to the job to avoid interrupting other jobs in process. This function collects them and raises a MultiJobFailure if failure has occurred; we do not differentiate between one failed jobs and multiple failed jobs

  • if you’re in the ‘multi-job’ path (eg, multiple targets) we handle

all errors the same to provide a consistent UX when running with mulitiple targets.



384
385
386
387
388
# File 'lib/chef_apply/cli.rb', line 384

def handle_job_failures(jobs)
  failed_jobs = jobs.select { |j| !j.exception.nil? }
  return if failed_jobs.empty?
  raise ChefApply::MultiJobFailure.new(failed_jobs)
end

#handle_message(message, data, reporter) ⇒ Object

A handler for common action messages



391
392
393
394
395
396
# File 'lib/chef_apply/cli.rb', line 391

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(ChefApply::UI::ErrorPrinter.error_summary(data[0]))
  end
end

#handle_perform_error(e) ⇒ Object



364
365
366
367
368
369
370
371
372
373
374
375
376
# File 'lib/chef_apply/cli.rb', line 364

def handle_perform_error(e)
  id = e.respond_to?(:id) ? e.id : e.class.to_s
  # TODO: This is currently sending host information for certain ssh errors
  #       post release we need to scrub this data. For now I'm redacting the
  #       whole message.
  # message = e.respond_to?(:message) ? e.message : e.to_s
  Telemeter.capture(:error, exception: { id: id, message: "redacted" })
  wrapper = ChefApply::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

#handle_run_error(e) ⇒ Object



75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/chef_apply/cli.rb', line 75

def handle_run_error(e)
  case e
  when nil
    RC_OK
  when WrappedError
    UI::ErrorPrinter.show_error(e)
    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

#install(target_host, reporter) ⇒ Object

Runs the InstallChef action and renders UI updates as the action reports back



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
# File 'lib/chef_apply/cli.rb', line 306

def install(target_host, reporter)
  installer = Action::InstallChef.instance_for_target(target_host, check_only: !parsed_options[:install])
  context = TS.install_chef
  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
      meth = @multi_target ? :update : :success
      reporter.send(meth, context.already_present(target_host.installed_chef_version))
    when :install_complete
      meth = @multi_target ? :update : :success
      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.send(meth, message)
    else
      handle_message(event, data, reporter)
    end
  end
end

#perform_runObject



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
# File 'lib/chef_apply/cli.rb', line 93

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)
    configure_chef
    target_hosts = TargetResolver.new(cli_arguments.shift,
                                      parsed_options.delete(:protocol),
                                      parsed_options).targets
    temp_cookbook, initial_status_msg = generate_temp_cookbook(cli_arguments)
    local_policy_path = nil
    UI::Terminal.render_job(TS.generate_policyfile.generating) do |reporter|
      local_policy_path = create_local_policy(temp_cookbook)
      reporter.success(TS.generate_policyfile.success)
    end
    if target_hosts.length == 1
      # Note: UX discussed determined that when running with a single target,
      #       we'll use multiple lines to display status for the target.
      run_single_target(initial_status_msg, target_hosts[0], local_policy_path)
    else
      @multi_target = true
      # Multi-target will use one line per target.
      run_multi_target(initial_status_msg, target_hosts, local_policy_path)
    end
  end
rescue OptionParser::InvalidOption => e
  # 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

#recipe_strategy?(cli_arguments) ⇒ Boolean

Returns:

  • (Boolean)


277
278
279
# File 'lib/chef_apply/cli.rb', line 277

def recipe_strategy?(cli_arguments)
  cli_arguments.size == 1
end

#runObject



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/chef_apply/cli.rb', line 54

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.
  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
  Telemeter.commit
  exit @rc
end

#run_multi_target(initial_status_msg, target_hosts, local_policy_path) ⇒ Object



160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/chef_apply/cli.rb', line 160

def run_multi_target(initial_status_msg, target_hosts, local_policy_path)
  # Our multi-host UX does not show a line item per action,
  # but rather a line-item per connection.
  jobs = target_hosts.map do |target_host|
    # This 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)
      reporter.update(TS.install_chef.verifying)
      install(target_host, reporter)
      reporter.update(initial_status_msg)
      converge(reporter, local_policy_path, target_host)
    end
  end
  UI::Terminal.render_parallel_jobs(TS.converge.multi_header, jobs)
  handle_job_failures(jobs)
end

#run_single_target(initial_status_msg, target_host, local_policy_path) ⇒ Object



149
150
151
152
153
154
155
156
157
158
# File 'lib/chef_apply/cli.rb', line 149

def run_single_target(initial_status_msg, target_host, local_policy_path)
  connect_target(target_host)
  prefix = "[#{target_host.hostname}]"
  UI::Terminal.render_job(TS.install_chef.verifying, prefix: prefix) do |reporter|
    install(target_host, reporter)
  end
  UI::Terminal.render_job(initial_status_msg, prefix: "[#{target_host.hostname}]") do |reporter|
    converge(reporter, local_policy_path, target_host)
  end
end

#show_helpObject



402
403
404
# File 'lib/chef_apply/cli.rb', line 402

def show_help
  UI::Terminal.output format_help
end

#show_versionObject



452
453
454
# File 'lib/chef_apply/cli.rb', line 452

def show_version
  UI::Terminal.output T.version.show(ChefApply::VERSION)
end

#transform_property_value(value) ⇒ Object

Incoming properties are always read as a string from the command line. Depending on their type we should transform them so we do not try and pass a string to a resource property that expects an integer or boolean.



228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/chef_apply/cli.rb', line 228

def transform_property_value(value)
  case value
  when /^0/
    # when it is a zero leading value like "0777" don't turn
    # it into a number (this is a mode flag)
    value
  when /^\d+$/
    value.to_i
  when /(^(\d+)(\.)?(\d+)?)|(^(\d+)?(\.)(\d+))/
    value.to_f
  when /true/i
    true
  when /false/i
    false
  else
    value
  end
end

#usageObject



448
449
450
# File 'lib/chef_apply/cli.rb', line 448

def usage
  T.usage
end

#validate_params(params) ⇒ Object



182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/chef_apply/cli.rb', line 182

def validate_params(params)
  if params.size < 2
    raise OptionValidationError.new("CHEFVAL002", self)
  end
  if params.size == 2
    # Trying to specify a recipe to run remotely, no properties
    cb = params[1]
    if File.exist?(cb)
      # This is a path specification, and we know it is valid
    elsif cb =~ /^#{CB_MATCHER}$/ || cb =~ /^#{CB_MATCHER}::#{CB_MATCHER}$/
      # They are specifying a cookbook as 'cb_name' or 'cb_name::recipe'
    else
      raise OptionValidationError.new("CHEFVAL004", self, cb)
    end
  elsif params.size >= 3
    properties = params[3..-1]
    properties.each do |property|
      unless property =~ PROPERTY_MATCHER
        raise OptionValidationError.new("CHEFVAL003", self, property)
      end
    end
  end
end