chef-apply 0.1.21 → 0.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +31 -28
- data/i18n/en.yml +11 -0
- data/i18n/errors/en.yml +286 -171
- data/lib/chef_apply/action/converge_target.rb +11 -1
- data/lib/chef_apply/cli.rb +7 -2
- data/lib/chef_apply/config.rb +3 -1
- data/lib/chef_apply/startup.rb +23 -0
- data/lib/chef_apply/target_host.rb +6 -0
- data/lib/chef_apply/text.rb +10 -2
- data/lib/chef_apply/text/error_translation.rb +69 -0
- data/lib/chef_apply/ui/error_printer.rb +20 -22
- data/lib/chef_apply/ui/plain_text_element.rb +4 -1
- data/lib/chef_apply/ui/plain_text_header.rb +46 -0
- data/lib/chef_apply/ui/terminal.rb +25 -34
- data/lib/chef_apply/ui/terminal/job.rb +39 -0
- data/lib/chef_apply/version.rb +1 -1
- data/spec/unit/action/converge_target_spec.rb +52 -0
- data/spec/unit/cli_spec.rb +9 -3
- data/spec/unit/startup_spec.rb +42 -1
- data/spec/unit/text/error_translation_spec.rb +105 -0
- data/spec/unit/ui/error_printer_spec.rb +38 -14
- data/spec/unit/ui/terminal_spec.rb +4 -1
- metadata +6 -2
@@ -40,7 +40,7 @@ module ChefApply::Action
|
|
40
40
|
c = target_host.run_command(cmd_str)
|
41
41
|
target_host.run_command!("#{delete_folder} #{remote_dir_path}")
|
42
42
|
if c.exit_status == 0
|
43
|
-
ChefApply::Log.
|
43
|
+
ChefApply::Log.info(c.stdout)
|
44
44
|
notify(:success)
|
45
45
|
elsif c.exit_status == 35
|
46
46
|
notify(:reboot)
|
@@ -79,6 +79,16 @@ module ChefApply::Action
|
|
79
79
|
exception_handlers << reporter
|
80
80
|
EOM
|
81
81
|
|
82
|
+
# add the target host's log level value
|
83
|
+
# (we don't set a location because we want output to
|
84
|
+
# go in stdout for reporting back to chef-apply)
|
85
|
+
log_settings = ChefApply::Config.log
|
86
|
+
if !log_settings.target_level.nil?
|
87
|
+
workstation_rb << <<~EOM
|
88
|
+
log_level :#{log_settings.target_level}
|
89
|
+
EOM
|
90
|
+
end
|
91
|
+
|
82
92
|
# Maybe add data collector endpoint.
|
83
93
|
dc = ChefApply::Config.data_collector
|
84
94
|
if !dc.url.nil? && !dc.token.nil?
|
data/lib/chef_apply/cli.rb
CHANGED
@@ -35,6 +35,7 @@ require "chef_apply/target_resolver"
|
|
35
35
|
require "chef_apply/telemeter"
|
36
36
|
require "chef_apply/ui/error_printer"
|
37
37
|
require "chef_apply/ui/terminal"
|
38
|
+
require "chef_apply/ui/terminal/job"
|
38
39
|
|
39
40
|
module ChefApply
|
40
41
|
class CLI
|
@@ -130,12 +131,16 @@ module ChefApply
|
|
130
131
|
end
|
131
132
|
|
132
133
|
def render_cookbook_setup(arguments)
|
133
|
-
|
134
|
+
# TODO update Job so that it doesn't require prefix and host. As a data container,
|
135
|
+
# should these attributes even be required?
|
136
|
+
job = UI::Terminal::Job.new("", nil) do |reporter|
|
134
137
|
@temp_cookbook = generate_temp_cookbook(arguments, reporter)
|
135
138
|
end
|
136
|
-
UI::Terminal.render_job(
|
139
|
+
UI::Terminal.render_job("...", job)
|
140
|
+
job = UI::Terminal::Job.new("", nil) do |reporter|
|
137
141
|
@archive_file_location = generate_local_policy(reporter)
|
138
142
|
end
|
143
|
+
UI::Terminal.render_job("...", job)
|
139
144
|
end
|
140
145
|
|
141
146
|
def render_converge(target_hosts)
|
data/lib/chef_apply/config.rb
CHANGED
@@ -111,6 +111,8 @@ module ChefApply
|
|
111
111
|
config_context :log do
|
112
112
|
default(:level, "warn")
|
113
113
|
default(:location, File.join(WS_BASE_PATH, "logs/default.log"))
|
114
|
+
# set the log level for the target host's chef-client run
|
115
|
+
default(:target_level, nil)
|
114
116
|
end
|
115
117
|
|
116
118
|
config_context :cache do
|
@@ -128,7 +130,7 @@ module ChefApply
|
|
128
130
|
end
|
129
131
|
|
130
132
|
config_context :dev do
|
131
|
-
default(:spinner,
|
133
|
+
default(:spinner, true)
|
132
134
|
end
|
133
135
|
|
134
136
|
config_context :chef do
|
data/lib/chef_apply/startup.rb
CHANGED
@@ -34,6 +34,10 @@ module ChefApply
|
|
34
34
|
end
|
35
35
|
|
36
36
|
def run
|
37
|
+
# This component is not supported in ChefDK; an exception will be raised
|
38
|
+
# if running in that context.
|
39
|
+
verify_not_in_chefdk
|
40
|
+
|
37
41
|
# Some tasks we do only once in an installation:
|
38
42
|
first_run_tasks
|
39
43
|
|
@@ -68,6 +72,8 @@ module ChefApply
|
|
68
72
|
UI::Terminal.output(T.error.bad_config_file(e.path))
|
69
73
|
rescue ConfigPathNotProvided
|
70
74
|
UI::Terminal.output(T.error.missing_config_path)
|
75
|
+
rescue UnsupportedInstallation
|
76
|
+
UI::Terminal.output(T.error.unsupported_installation)
|
71
77
|
rescue Mixlib::Config::UnknownConfigOptionError => e
|
72
78
|
# Ideally we'd update the exception in mixlib to include
|
73
79
|
# a field with the faulty value, line number, and nested context -
|
@@ -89,6 +95,15 @@ module ChefApply
|
|
89
95
|
UI::Terminal.init($stdout)
|
90
96
|
end
|
91
97
|
|
98
|
+
# Verify that chef-run gem is not executing out of ChefDK by checking the
|
99
|
+
# runtime path of this file.
|
100
|
+
#
|
101
|
+
# NOTE: This is imperfect - someone could theoretically
|
102
|
+
# install chefdk to a path other than the default.
|
103
|
+
def verify_not_in_chefdk
|
104
|
+
raise UnsupportedInstallation.new if script_path =~ /chefdk/
|
105
|
+
end
|
106
|
+
|
92
107
|
def first_run_tasks
|
93
108
|
return if Dir.exist?(Config::WS_BASE_PATH)
|
94
109
|
create_default_config
|
@@ -173,6 +188,13 @@ module ChefApply
|
|
173
188
|
require "chef_apply/cli"
|
174
189
|
ChefApply::CLI.new(@argv).run
|
175
190
|
end
|
191
|
+
|
192
|
+
private
|
193
|
+
|
194
|
+
def script_path
|
195
|
+
File.expand_path File.dirname(__FILE__)
|
196
|
+
end
|
197
|
+
|
176
198
|
class ConfigPathNotProvided < StandardError; end
|
177
199
|
class ConfigPathInvalid < StandardError
|
178
200
|
attr_reader :path
|
@@ -180,5 +202,6 @@ module ChefApply
|
|
180
202
|
@path = path
|
181
203
|
end
|
182
204
|
end
|
205
|
+
class UnsupportedInstallation < StandardError; end
|
183
206
|
end
|
184
207
|
end
|
@@ -45,6 +45,12 @@ module ChefApply
|
|
45
45
|
sudo: opts_in[:sudo] === false ? false : true,
|
46
46
|
www_form_encoded_password: true,
|
47
47
|
key_files: opts_in[:identity_file],
|
48
|
+
non_interactive: true,
|
49
|
+
# Prevent long delays due to retries on auth failure.
|
50
|
+
# This does reduce the number of attempts we'll make for transient conditions as well, but
|
51
|
+
# train does not currently exposes these as separate controls. Ideally I'd like to see a 'retry_on_auth_failure' option.
|
52
|
+
connection_retries: 2,
|
53
|
+
connection_retry_sleep: 0.15,
|
48
54
|
logger: ChefApply::Log }
|
49
55
|
if opts_in.has_key? :ssl
|
50
56
|
connection_opts[:ssl] = opts_in[:ssl]
|
data/lib/chef_apply/text.rb
CHANGED
@@ -17,11 +17,20 @@
|
|
17
17
|
|
18
18
|
require "r18n-desktop"
|
19
19
|
require "chef_apply/text/text_wrapper"
|
20
|
+
require "chef_apply/text/error_translation"
|
20
21
|
|
21
22
|
# A very thin wrapper around R18n, the idea being that we're likely to replace r18n
|
22
23
|
# down the road and don't want to have to change all of our commands.
|
23
24
|
module ChefApply
|
24
25
|
module Text
|
26
|
+
def self._error_table
|
27
|
+
# Though ther may be several translations, en.yml will be the only one with
|
28
|
+
# error metadata.
|
29
|
+
path = File.join(_translation_path, "errors", "en.yml")
|
30
|
+
raw_yaml = File.read(path)
|
31
|
+
@error_table ||= YAML.load(raw_yaml, _translation_path, symbolize_names: true)[:errors]
|
32
|
+
end
|
33
|
+
|
25
34
|
def self._translation_path
|
26
35
|
@translation_path ||= File.join(File.dirname(__FILE__), "..", "..", "i18n")
|
27
36
|
end
|
@@ -38,8 +47,7 @@ module ChefApply
|
|
38
47
|
end
|
39
48
|
end
|
40
49
|
|
41
|
-
#
|
50
|
+
# Load on class load to ensure our text accessor methods are available from the start.
|
42
51
|
load
|
43
52
|
end
|
44
|
-
|
45
53
|
end
|
@@ -0,0 +1,69 @@
|
|
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
|
+
module Text
|
20
|
+
class ErrorTranslation
|
21
|
+
ATTRIBUTES = :decorations, :header, :footer, :stack, :log
|
22
|
+
attr_reader :message, *ATTRIBUTES
|
23
|
+
|
24
|
+
def initialize(id, params: [])
|
25
|
+
# To get access to the metadata we'll go directly through the parsed yaml.
|
26
|
+
# Accessing via R18n is unnecessarily complicated
|
27
|
+
yml = Text._error_table
|
28
|
+
|
29
|
+
# We'll still use our Text mechanism for the text itself so that
|
30
|
+
# parameters, pluralization, etc will still work.
|
31
|
+
# This will raise if the key doesn't exist.
|
32
|
+
@message = Text.errors.send(id).text(*params)
|
33
|
+
options = yml[:display_defaults]
|
34
|
+
|
35
|
+
# Override any defaults if display metadata is given
|
36
|
+
display_opts = yml[id.to_sym][:display]
|
37
|
+
options = options.merge(display_opts) unless display_opts.nil?
|
38
|
+
|
39
|
+
ATTRIBUTES.each do |attribute|
|
40
|
+
instance_variable_set("@#{attribute}", options.delete(attribute))
|
41
|
+
end
|
42
|
+
|
43
|
+
if options.length > 0
|
44
|
+
# Anything not in ATTRIBUTES is not supported. This will also catch
|
45
|
+
# typos in attr names
|
46
|
+
raise InvalidDisplayAttributes.new(id, options)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def inspect
|
51
|
+
inspection = "#{self}: "
|
52
|
+
ATTRIBUTES.each do |attribute|
|
53
|
+
inspection << "#{attribute}: #{send(attribute.to_s)}; "
|
54
|
+
end
|
55
|
+
inspection << "message: #{message.gsub("\n", "\\n")}"
|
56
|
+
inspection
|
57
|
+
end
|
58
|
+
|
59
|
+
class InvalidDisplayAttributes < RuntimeError
|
60
|
+
attr_reader :invalid_attrs
|
61
|
+
def initialize(id, attrs)
|
62
|
+
@invalid_attrs = attrs
|
63
|
+
super("Invalid display attributes found for #{id}: #{attrs}")
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -25,7 +25,8 @@ require "chef_apply/errors/standard_error_resolver"
|
|
25
25
|
|
26
26
|
module ChefApply::UI
|
27
27
|
class ErrorPrinter
|
28
|
-
attr_reader :id, :pastel, :
|
28
|
+
attr_reader :id, :pastel, :translation, :exception, :target_host
|
29
|
+
|
29
30
|
# TODO define 't' as a method is a temporary workaround
|
30
31
|
# to ensure that text key lookups are testable.
|
31
32
|
def t
|
@@ -91,27 +92,22 @@ module ChefApply::UI
|
|
91
92
|
def initialize(wrapper, unwrapped = nil, target_host = nil)
|
92
93
|
@exception = unwrapped || wrapper.contained_exception
|
93
94
|
@target_host = wrapper.target_host || target_host
|
95
|
+
@command = exception.respond_to?(:command) ? exception.command : nil
|
94
96
|
@pastel = Pastel.new
|
95
|
-
@show_log = exception.respond_to?(:show_log) ? exception.show_log : true
|
96
|
-
@show_stack = exception.respond_to?(:show_stack) ? exception.show_stack : true
|
97
97
|
@content = StringIO.new
|
98
|
-
@
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
@decorate = exception.decorate
|
105
|
-
else
|
106
|
-
@decorate = true
|
107
|
-
end
|
98
|
+
@id = if exception.kind_of? ChefApply::Error
|
99
|
+
exception.id
|
100
|
+
else
|
101
|
+
DEFAULT_ERROR_NO
|
102
|
+
end
|
103
|
+
@translation = ChefApply::Text::ErrorTranslation.new(id)
|
108
104
|
rescue => e
|
109
105
|
ErrorPrinter.dump_unexpected_error(e)
|
110
106
|
exit! 128
|
111
107
|
end
|
112
108
|
|
113
109
|
def format_error
|
114
|
-
if
|
110
|
+
if translation.decorations
|
115
111
|
format_decorated
|
116
112
|
else
|
117
113
|
format_undecorated
|
@@ -153,15 +149,15 @@ module ChefApply::UI
|
|
153
149
|
end
|
154
150
|
|
155
151
|
def format_footer
|
156
|
-
if
|
157
|
-
if
|
152
|
+
if translation.log
|
153
|
+
if translation.stack
|
158
154
|
t.footer.both(ChefApply::Config.log.location,
|
159
155
|
ChefApply::Config.stack_trace_path)
|
160
156
|
else
|
161
157
|
t.footer.log_only(ChefApply::Config.log.location)
|
162
158
|
end
|
163
159
|
else
|
164
|
-
if
|
160
|
+
if translation.stack
|
165
161
|
t.footer.stack_only
|
166
162
|
else
|
167
163
|
t.footer.neither
|
@@ -185,7 +181,7 @@ module ChefApply::UI
|
|
185
181
|
def self.error_summary(e)
|
186
182
|
if e.kind_of? ChefApply::Error
|
187
183
|
# By convention, all of our defined messages have a short summary on the first line.
|
188
|
-
ChefApply::Text.errors.send(e.id
|
184
|
+
ChefApply::Text.errors.send(e.id).text(*e.params).split("\n").first
|
189
185
|
elsif e.kind_of? String
|
190
186
|
e
|
191
187
|
else
|
@@ -199,20 +195,22 @@ module ChefApply::UI
|
|
199
195
|
|
200
196
|
def format_workstation_exception
|
201
197
|
params = exception.params
|
202
|
-
t.send(@id
|
198
|
+
t.send(@id).text(*params)
|
203
199
|
end
|
204
200
|
|
201
|
+
# TODO this gets moved to trainerrormapper or simply removed since
|
202
|
+
# many of these issues are now handled in the RemoteTarget::ConnectionFailure
|
205
203
|
def format_train_exception
|
206
204
|
backend, host = formatted_host()
|
207
205
|
if host.nil?
|
208
|
-
t.CHEFTRN002(exception.message)
|
206
|
+
t.CHEFTRN002.text(exception.message)
|
209
207
|
else
|
210
|
-
t.CHEFTRN001(backend, host, exception.message)
|
208
|
+
t.CHEFTRN001.text(backend, host, exception.message)
|
211
209
|
end
|
212
210
|
end
|
213
211
|
|
214
212
|
def format_other_exception
|
215
|
-
t.send(DEFAULT_ERROR_NO
|
213
|
+
t.send(DEFAULT_ERROR_NO).text(exception.message)
|
216
214
|
end
|
217
215
|
|
218
216
|
def formatted_host
|
@@ -29,7 +29,7 @@ module ChefApply
|
|
29
29
|
end
|
30
30
|
|
31
31
|
def update(params)
|
32
|
-
#
|
32
|
+
# Some of this is particular to our usage -
|
33
33
|
# prefix does not cause a text update, but does
|
34
34
|
# change the prefix for future messages.
|
35
35
|
if params.has_key?(:prefix)
|
@@ -70,6 +70,9 @@ module ChefApply
|
|
70
70
|
@succ = true
|
71
71
|
@err = false
|
72
72
|
end
|
73
|
+
|
74
|
+
def auto_spin
|
75
|
+
end
|
73
76
|
end
|
74
77
|
end
|
75
78
|
end
|
@@ -0,0 +1,46 @@
|
|
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
|
+
require "thread"
|
18
|
+
require "chef_apply/ui/plain_text_element"
|
19
|
+
|
20
|
+
module ChefApply
|
21
|
+
module UI
|
22
|
+
class PlainTextHeader
|
23
|
+
def initialize(format, opts)
|
24
|
+
@format = format
|
25
|
+
@output = opts[:output]
|
26
|
+
@children = {}
|
27
|
+
@threads = []
|
28
|
+
end
|
29
|
+
|
30
|
+
def register(child_format, child_opts, &block)
|
31
|
+
child_opts[:output] = @output
|
32
|
+
child = PlainTextElement.new(child_format, child_opts)
|
33
|
+
@children[child] = block
|
34
|
+
end
|
35
|
+
|
36
|
+
def auto_spin
|
37
|
+
msg = @format.gsub(/:spinner/, " HEADER ")
|
38
|
+
@output.puts(msg)
|
39
|
+
@children.each do |child, block|
|
40
|
+
@threads << Thread.new { block.call(child) }
|
41
|
+
end
|
42
|
+
@threads.each { |thr| thr.join }
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -20,27 +20,11 @@ require "chef_apply/status_reporter"
|
|
20
20
|
require "chef_apply/config"
|
21
21
|
require "chef_apply/log"
|
22
22
|
require "chef_apply/ui/plain_text_element"
|
23
|
+
require "chef_apply/ui/plain_text_header"
|
23
24
|
|
24
25
|
module ChefApply
|
25
26
|
module UI
|
26
27
|
class Terminal
|
27
|
-
class Job
|
28
|
-
attr_reader :proc, :prefix, :target_host, :exception
|
29
|
-
def initialize(prefix, target_host, &block)
|
30
|
-
@proc = block
|
31
|
-
@prefix = prefix
|
32
|
-
@target_host = target_host
|
33
|
-
@error = nil
|
34
|
-
end
|
35
|
-
|
36
|
-
def run(reporter)
|
37
|
-
@proc.call(reporter)
|
38
|
-
rescue => e
|
39
|
-
reporter.error(e.to_s)
|
40
|
-
@exception = e
|
41
|
-
end
|
42
|
-
end
|
43
|
-
|
44
28
|
class << self
|
45
29
|
# To support matching in test
|
46
30
|
attr_accessor :location
|
@@ -58,31 +42,30 @@ module ChefApply
|
|
58
42
|
end
|
59
43
|
|
60
44
|
def render_parallel_jobs(header, jobs)
|
61
|
-
|
45
|
+
# Do not indent the topmost 'parent' spinner, but do indent child spinners
|
62
46
|
indent_style = { top: "",
|
63
47
|
middle: TTY::Spinner::Multi::DEFAULT_INSET[:middle],
|
64
48
|
bottom: TTY::Spinner::Multi::DEFAULT_INSET[:bottom] }
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
multispinner =
|
69
|
-
jobs.each do |
|
70
|
-
multispinner.register(spinner_prefix(
|
71
|
-
reporter = StatusReporter.new(spinner, prefix:
|
72
|
-
|
49
|
+
# @option options [Hash] :style
|
50
|
+
# keys :top :middle and :bottom can contain Strings that are used to
|
51
|
+
# indent the spinners. Ignored if message is blank
|
52
|
+
multispinner = get_multispinner.new("[:spinner] #{header}", output: @location, hide_cursor: true, style: indent_style)
|
53
|
+
jobs.each do |job|
|
54
|
+
multispinner.register(spinner_prefix(job.prefix), hide_cursor: true) do |spinner|
|
55
|
+
reporter = StatusReporter.new(spinner, prefix: job.prefix, key: :status)
|
56
|
+
job.run(reporter)
|
73
57
|
end
|
74
58
|
end
|
75
59
|
multispinner.auto_spin
|
76
60
|
end
|
77
61
|
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
spinner.run { yield(reporter) }
|
62
|
+
def render_job(initial_msg, job)
|
63
|
+
# TODO why do we have to pass prefix to both the spinner and the reporter?
|
64
|
+
spinner = get_spinner.new(spinner_prefix(job.prefix), output: @location, hide_cursor: true)
|
65
|
+
reporter = StatusReporter.new(spinner, prefix: job.prefix, key: :status)
|
66
|
+
reporter.update(initial_msg)
|
67
|
+
spinner.auto_spin
|
68
|
+
job.run(reporter)
|
86
69
|
end
|
87
70
|
|
88
71
|
def spinner_prefix(prefix)
|
@@ -90,6 +73,14 @@ module ChefApply
|
|
90
73
|
spinner_msg += ":prefix " unless prefix.empty?
|
91
74
|
spinner_msg + ":status"
|
92
75
|
end
|
76
|
+
|
77
|
+
def get_multispinner
|
78
|
+
ChefApply::Config.dev.spinner ? TTY::Spinner::Multi : PlainTextHeader
|
79
|
+
end
|
80
|
+
|
81
|
+
def get_spinner
|
82
|
+
ChefApply::Config.dev.spinner ? TTY::Spinner : PlainTextElement
|
83
|
+
end
|
93
84
|
end
|
94
85
|
end
|
95
86
|
end
|