bolt 2.36.0 → 2.42.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of bolt might be problematic. Click here for more details.

Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/Puppetfile +8 -8
  3. data/lib/bolt/bolt_option_parser.rb +7 -3
  4. data/lib/bolt/cli.rb +67 -23
  5. data/lib/bolt/config.rb +70 -45
  6. data/lib/bolt/config/options.rb +104 -79
  7. data/lib/bolt/config/transport/base.rb +2 -2
  8. data/lib/bolt/config/transport/local.rb +1 -0
  9. data/lib/bolt/config/transport/options.rb +11 -68
  10. data/lib/bolt/config/transport/ssh.rb +0 -5
  11. data/lib/bolt/inventory.rb +26 -0
  12. data/lib/bolt/inventory/group.rb +29 -9
  13. data/lib/bolt/inventory/inventory.rb +1 -1
  14. data/lib/bolt/inventory/options.rb +130 -0
  15. data/lib/bolt/inventory/target.rb +10 -11
  16. data/lib/bolt/module.rb +10 -2
  17. data/lib/bolt/module_installer.rb +21 -13
  18. data/lib/bolt/module_installer/resolver.rb +13 -5
  19. data/lib/bolt/outputter.rb +19 -5
  20. data/lib/bolt/outputter/human.rb +20 -1
  21. data/lib/bolt/outputter/json.rb +1 -1
  22. data/lib/bolt/outputter/logger.rb +1 -1
  23. data/lib/bolt/outputter/rainbow.rb +12 -1
  24. data/lib/bolt/pal/yaml_plan/transpiler.rb +5 -1
  25. data/lib/bolt/plugin.rb +42 -6
  26. data/lib/bolt/plugin/cache.rb +76 -0
  27. data/lib/bolt/plugin/module.rb +4 -4
  28. data/lib/bolt/plugin/puppetdb.rb +1 -1
  29. data/lib/bolt/project.rb +38 -13
  30. data/lib/bolt/project_manager.rb +2 -0
  31. data/lib/bolt/project_manager/config_migrator.rb +9 -1
  32. data/lib/bolt/project_manager/module_migrator.rb +2 -0
  33. data/lib/bolt/puppetdb/client.rb +8 -0
  34. data/lib/bolt/rerun.rb +1 -5
  35. data/lib/bolt/shell/bash.rb +7 -1
  36. data/lib/bolt/shell/powershell.rb +21 -3
  37. data/lib/bolt/target.rb +4 -0
  38. data/lib/bolt/transport/local.rb +13 -0
  39. data/lib/bolt/util.rb +22 -0
  40. data/lib/bolt/validator.rb +227 -0
  41. data/lib/bolt/version.rb +1 -1
  42. data/lib/bolt_server/plugin.rb +13 -0
  43. data/lib/bolt_server/plugin/puppet_connect_data.rb +37 -0
  44. data/lib/bolt_server/schemas/connect-data.json +22 -0
  45. data/lib/bolt_server/schemas/partials/task.json +1 -1
  46. data/lib/bolt_server/transport_app.rb +64 -36
  47. metadata +24 -5
  48. data/lib/bolt/config/validator.rb +0 -231
@@ -9,7 +9,7 @@ module Bolt
9
9
  class Resolver
10
10
  # Resolves module specs and returns a Puppetfile object.
11
11
  #
12
- def resolve(specs)
12
+ def resolve(specs, config = {})
13
13
  require 'puppetfile-resolver'
14
14
 
15
15
  # Build the document model from the specs.
@@ -38,10 +38,10 @@ module Bolt
38
38
  # raised by puppetfile-resolver and re-raising them as Bolt errors.
39
39
  begin
40
40
  result = resolver.resolve(
41
- cache: nil,
42
- ui: nil,
43
- module_paths: [],
44
- allow_missing_modules: false
41
+ cache: nil,
42
+ ui: nil,
43
+ allow_missing_modules: false,
44
+ spec_searcher_configuration: spec_searcher_config(config)
45
45
  )
46
46
  rescue StandardError => e
47
47
  raise Bolt::Error.new(e.message, 'bolt/module-resolver-error')
@@ -71,6 +71,14 @@ module Bolt
71
71
  # Create the Puppetfile object.
72
72
  Bolt::ModuleInstaller::Puppetfile.new(modules)
73
73
  end
74
+
75
+ private def spec_searcher_config(config)
76
+ PuppetfileResolver::SpecSearchers::Configuration.new.tap do |obj|
77
+ obj.forge.proxy = config.dig('forge', 'proxy') || config.dig('proxy')
78
+ obj.git.proxy = config.dig('proxy')
79
+ obj.forge.forge_api = config.dig('forge', 'baseurl')
80
+ end
81
+ end
74
82
  end
75
83
  end
76
84
  end
@@ -2,24 +2,25 @@
2
2
 
3
3
  module Bolt
4
4
  class Outputter
5
- def self.for_format(format, color, verbose, trace)
5
+ def self.for_format(format, color, verbose, trace, spin)
6
6
  case format
7
7
  when 'human'
8
- Bolt::Outputter::Human.new(color, verbose, trace)
8
+ Bolt::Outputter::Human.new(color, verbose, trace, spin)
9
9
  when 'json'
10
- Bolt::Outputter::JSON.new(color, verbose, trace)
10
+ Bolt::Outputter::JSON.new(color, verbose, trace, false)
11
11
  when 'rainbow'
12
- Bolt::Outputter::Rainbow.new(color, verbose, trace)
12
+ Bolt::Outputter::Rainbow.new(color, verbose, trace, spin)
13
13
  when nil
14
14
  raise "Cannot use outputter before parsing."
15
15
  end
16
16
  end
17
17
 
18
- def initialize(color, verbose, trace, stream = $stdout)
18
+ def initialize(color, verbose, trace, spin, stream = $stdout)
19
19
  @color = color
20
20
  @verbose = verbose
21
21
  @trace = trace
22
22
  @stream = stream
23
+ @spin = spin
23
24
  end
24
25
 
25
26
  def indent(indent, string)
@@ -34,6 +35,19 @@ module Bolt
34
35
  def print_error
35
36
  raise NotImplementedError, "print_error() must be implemented by the outputter class"
36
37
  end
38
+
39
+ def start_spin; end
40
+
41
+ def stop_spin; end
42
+
43
+ def spin
44
+ start_spin
45
+ begin
46
+ yield
47
+ ensure
48
+ stop_spin
49
+ end
50
+ end
37
51
  end
38
52
  end
39
53
 
@@ -14,12 +14,13 @@ module Bolt
14
14
 
15
15
  def print_head; end
16
16
 
17
- def initialize(color, verbose, trace, stream = $stdout)
17
+ def initialize(color, verbose, trace, spin, stream = $stdout)
18
18
  super
19
19
  # Plans and without_default_logging() calls can both be nested, so we
20
20
  # track each of them with a "stack" consisting of an integer.
21
21
  @plan_depth = 0
22
22
  @disable_depth = 0
23
+ @pinwheel = %w[- \\ | /]
23
24
  end
24
25
 
25
26
  def colorize(color, string)
@@ -30,6 +31,24 @@ module Bolt
30
31
  end
31
32
  end
32
33
 
34
+ def start_spin
35
+ return unless @spin && @stream.isatty
36
+ @spin = true
37
+ @spin_thread = Thread.new do
38
+ loop do
39
+ sleep(0.1)
40
+ @stream.print(colorize(:cyan, @pinwheel.rotate!.first + "\b"))
41
+ end
42
+ end
43
+ end
44
+
45
+ def stop_spin
46
+ return unless @spin && @stream.isatty
47
+ @spin_thread.terminate
48
+ @spin = false
49
+ @stream.print("\b")
50
+ end
51
+
33
52
  def remove_trail(string)
34
53
  string.sub(/\s\z/, '')
35
54
  end
@@ -3,7 +3,7 @@
3
3
  module Bolt
4
4
  class Outputter
5
5
  class JSON < Bolt::Outputter
6
- def initialize(color, verbose, trace, stream = $stdout)
6
+ def initialize(color, verbose, trace, spin, stream = $stdout)
7
7
  super
8
8
  @items_open = false
9
9
  @object_open = false
@@ -6,7 +6,7 @@ module Bolt
6
6
  class Outputter
7
7
  class Logger < Bolt::Outputter
8
8
  def initialize(verbose, trace)
9
- super(false, verbose, trace)
9
+ super(false, verbose, trace, false)
10
10
  @logger = Bolt::Logger.logger(self)
11
11
  end
12
12
 
@@ -5,7 +5,7 @@ require 'bolt/pal'
5
5
  module Bolt
6
6
  class Outputter
7
7
  class Rainbow < Bolt::Outputter::Human
8
- def initialize(color, verbose, trace, stream = $stdout)
8
+ def initialize(color, verbose, trace, spin, stream = $stdout)
9
9
  begin
10
10
  require 'paint'
11
11
  if Bolt::Util.windows?
@@ -62,6 +62,17 @@ module Bolt
62
62
  end
63
63
  end
64
64
 
65
+ def start_spin
66
+ return unless @spin && @stream.isatty
67
+ @spin = true
68
+ @spin_thread = Thread.new do
69
+ loop do
70
+ @stream.print(colorize(:rainbow, @pinwheel.rotate!.first + "\b"))
71
+ sleep(0.1)
72
+ end
73
+ end
74
+ end
75
+
65
76
  def print_summary(results, elapsed_time = nil)
66
77
  ok_set = results.ok_set
67
78
  unless ok_set.empty?
@@ -64,7 +64,11 @@ module Bolt
64
64
  raise Bolt::FileError.new(msg, @plan_path)
65
65
  end
66
66
 
67
- Bolt::PAL::YamlPlan::Loader.from_string(@modulename, file_contents, @plan_path)
67
+ begin
68
+ Bolt::PAL::YamlPlan::Loader.from_string(@modulename, file_contents, @plan_path)
69
+ rescue Puppet::PreformattedError, StandardError => e
70
+ raise PALError.from_preformatted_error(e)
71
+ end
68
72
  end
69
73
 
70
74
  def validate_path
@@ -4,6 +4,7 @@ require 'bolt/inventory'
4
4
  require 'bolt/executor'
5
5
  require 'bolt/module'
6
6
  require 'bolt/pal'
7
+ require 'bolt/plugin/cache'
7
8
  require 'bolt/plugin/puppetdb'
8
9
 
9
10
  module Bolt
@@ -36,6 +37,13 @@ module Bolt
36
37
  super("Plugin #{plugin_name} does not support #{hook}", 'bolt/unsupported-hook')
37
38
  end
38
39
  end
40
+
41
+ class LoadingDisabled < PluginError
42
+ def initialize(plugin_name)
43
+ msg = "Cannot load plugin #{plugin_name}: plugin loading is disabled"
44
+ super(msg, 'bolt/plugin-loading-disabled', { 'plugin_name' => plugin_name })
45
+ end
46
+ end
39
47
  end
40
48
 
41
49
  class PluginContext
@@ -119,8 +127,8 @@ module Bolt
119
127
  end
120
128
  end
121
129
 
122
- def self.setup(config, pal, analytics = Bolt::Analytics::NoopClient.new)
123
- plugins = new(config, pal, analytics)
130
+ def self.setup(config, pal, analytics = Bolt::Analytics::NoopClient.new, **opts)
131
+ plugins = new(config, pal, analytics, **opts)
124
132
 
125
133
  config.plugins.each_key do |plugin|
126
134
  plugins.by_name(plugin)
@@ -141,12 +149,13 @@ module Bolt
141
149
 
142
150
  private_class_method :new
143
151
 
144
- def initialize(config, pal, analytics)
152
+ def initialize(config, pal, analytics, load_plugins: true)
145
153
  @config = config
146
154
  @analytics = analytics
147
155
  @plugin_context = PluginContext.new(config, pal, self)
148
156
  @plugins = {}
149
157
  @pal = pal
158
+ @load_plugins = load_plugins
150
159
  @unknown = Set.new
151
160
  @resolution_stack = []
152
161
  @unresolved_plugin_configs = config.plugins.dup
@@ -161,7 +170,7 @@ module Bolt
161
170
  end
162
171
 
163
172
  def modules
164
- @modules ||= Bolt::Module.discover(@pal.full_modulepath)
173
+ @modules ||= Bolt::Module.discover(@pal.full_modulepath, @config.project)
165
174
  end
166
175
 
167
176
  def add_plugin(plugin)
@@ -169,6 +178,8 @@ module Bolt
169
178
  end
170
179
 
171
180
  def add_ruby_plugin(plugin_name)
181
+ raise PluginError::LoadingDisabled, plugin_name unless @load_plugins
182
+
172
183
  cls_name = Bolt::Util.snake_name_to_class_name(plugin_name)
173
184
  filename = "bolt/plugin/#{plugin_name}"
174
185
  require filename
@@ -185,10 +196,17 @@ module Bolt
185
196
  def add_module_plugin(plugin_name)
186
197
  opts = {
187
198
  context: @plugin_context,
199
+ # Make sure that the plugin's config is validated _before_ the unknown-plugin
200
+ # and loading-disabled checks. This way, we can fail early on invalid plugin
201
+ # config instead of _after_ loading the modulepath (which can be expensive).
188
202
  config: config_for_plugin(plugin_name)
189
203
  }
190
204
 
191
- plugin = Bolt::Plugin::Module.load(plugin_name, modules, opts)
205
+ mod = modules[plugin_name]
206
+ raise PluginError::Unknown, plugin_name unless mod&.plugin?
207
+ raise PluginError::LoadingDisabled, plugin_name unless @load_plugins
208
+
209
+ plugin = Bolt::Plugin::Module.load(mod, opts)
192
210
  add_plugin(plugin)
193
211
  end
194
212
 
@@ -277,6 +295,16 @@ module Bolt
277
295
  # Evaluates a single reference. The value returned may be another
278
296
  # reference.
279
297
  def resolve_single_reference(reference)
298
+ plugin_cache = if cache?(reference)
299
+ cache = Bolt::Plugin::Cache.new(reference,
300
+ @config.project.cache_file,
301
+ @config.plugin_cache)
302
+ entry = cache.read_and_clean_cache
303
+ return entry unless entry.nil?
304
+
305
+ cache
306
+ end
307
+
280
308
  plugin_name = reference['_plugin']
281
309
  hook = get_hook(plugin_name, :resolve_reference)
282
310
 
@@ -288,16 +316,24 @@ module Bolt
288
316
 
289
317
  validate_proc.call(reference)
290
318
 
291
- begin
319
+ result = begin
292
320
  # Evaluate the plugin and then recursively evaluate any plugin returned by it.
293
321
  hook.call(reference)
294
322
  rescue StandardError => e
295
323
  loc = "resolve_reference in #{plugin_name}"
296
324
  raise PluginError::ExecutionError.new(e.message, plugin_name, loc)
297
325
  end
326
+
327
+ plugin_cache.write_cache(result) if cache?(reference)
328
+
329
+ result
298
330
  end
299
331
  private :resolve_single_reference
300
332
 
333
+ private def cache?(reference)
334
+ reference.key?('_cache') || @config.plugin_cache.key?('ttl')
335
+ end
336
+
301
337
  # Checks whether a given value is a _plugin reference
302
338
  def reference?(input)
303
339
  input.is_a?(Hash) && input.key?('_plugin')
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'bolt/error'
5
+ require 'bolt/util'
6
+
7
+ module Bolt
8
+ class Plugin
9
+ class Cache
10
+ attr_reader :reference, :cache_file, :default_config, :id
11
+
12
+ def initialize(reference, cache_file, default_config)
13
+ @reference = reference
14
+ @cache_file = cache_file
15
+ @default_config = default_config
16
+ end
17
+
18
+ def read_and_clean_cache
19
+ return if ttl == 0
20
+ validate
21
+
22
+ # Luckily we don't need to use a serious hash algorithm
23
+ require 'digest/bubblebabble'
24
+ r = reference.reject { |k, _| k == '_cache' }.sort.to_s
25
+ @id = Digest::SHA2.bubblebabble(r)[0..20]
26
+
27
+ unmodified = true
28
+ # First remove any cache entries past their ttl
29
+ # This prevents removing plugins from leaving orphaned cache entries
30
+ cache.delete_if do |_, entry|
31
+ expired = Time.now - Time.parse(entry['mtime']) >= entry['ttl']
32
+ unmodified = false if expired
33
+ expired
34
+ end
35
+ File.write(cache_file, cache.to_json) unless cache.empty? || unmodified
36
+
37
+ cache.dig(id, 'result')
38
+ end
39
+
40
+ private def cache
41
+ @cache ||= Bolt::Util.read_optional_json_file(@cache_file, 'cache')
42
+ end
43
+
44
+ def write_cache(result)
45
+ cache.merge!({ id => { 'result' => result,
46
+ 'mtime' => Time.now,
47
+ 'ttl' => ttl } })
48
+ FileUtils.touch(cache_file)
49
+ File.write(cache_file, cache.to_json)
50
+ end
51
+
52
+ def validate
53
+ # The default cache `plugin-cache` will be validated by the config
54
+ # validator
55
+ return if reference['_cache'].nil?
56
+ r = reference['_cache']
57
+ unless r.is_a?(Hash)
58
+ raise Bolt::ValidationError,
59
+ "_cache must be a Hash, received #{r.class}: #{r.inspect}"
60
+ end
61
+
62
+ unless r.key?('ttl')
63
+ raise Bolt::ValidationError, "_cache must set 'ttl' key."
64
+ end
65
+
66
+ unless r['ttl'] >= 0
67
+ raise Bolt::ValidationError, "'ttl' key under '_cache' must be a minimum of 0."
68
+ end
69
+ end
70
+
71
+ private def ttl
72
+ @ttl ||= reference.dig('_cache', 'ttl') || default_config['ttl']
73
+ end
74
+ end
75
+ end
76
+ end
@@ -12,15 +12,15 @@ module Bolt
12
12
  end
13
13
  end
14
14
 
15
- def self.load(name, modules, opts)
16
- mod = modules[name]
17
- if mod&.plugin?
15
+ # mod should not be nil
16
+ def self.load(mod, opts)
17
+ if mod.plugin?
18
18
  opts[:mod] = mod
19
19
  plugin = Bolt::Plugin::Module.new(**opts)
20
20
  plugin.setup
21
21
  plugin
22
22
  else
23
- raise PluginError::Unknown, name
23
+ raise PluginError::Unknown, mod.name
24
24
  end
25
25
  end
26
26
 
@@ -12,7 +12,7 @@ module Bolt
12
12
  end
13
13
 
14
14
  TEMPLATE_OPTS = %w[alias config facts features name uri vars].freeze
15
- PLUGIN_OPTS = %w[_plugin query target_mapping].freeze
15
+ PLUGIN_OPTS = %w[_plugin _cache query target_mapping].freeze
16
16
 
17
17
  attr_reader :puppetdb_client
18
18
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'pathname'
4
4
  require 'bolt/config'
5
- require 'bolt/config/validator'
5
+ require 'bolt/validator'
6
6
  require 'bolt/pal'
7
7
  require 'bolt/module'
8
8
 
@@ -14,7 +14,7 @@ module Bolt
14
14
  attr_reader :path, :data, :config_file, :inventory_file, :hiera_config,
15
15
  :puppetfile, :rerunfile, :type, :resource_types, :logs, :project_file,
16
16
  :deprecations, :downloads, :plans_path, :modulepath, :managed_moduledir,
17
- :backup_dir
17
+ :backup_dir, :cache_file
18
18
 
19
19
  def self.default_project(logs = [])
20
20
  create_project(File.expand_path(File.join('~', '.puppetlabs', 'bolt')), 'user', logs)
@@ -27,23 +27,31 @@ module Bolt
27
27
  # directory called Boltdir or a file called bolt.yaml (for a control repo
28
28
  # type Project). Otherwise, repeat the check on each directory up the
29
29
  # hierarchy, falling back to the default if we reach the root.
30
- def self.find_boltdir(dir, logs = [])
30
+ def self.find_boltdir(dir, logs = [], deprecations = [])
31
31
  dir = Pathname.new(dir)
32
32
 
33
33
  if (dir + BOLTDIR_NAME).directory?
34
34
  create_project(dir + BOLTDIR_NAME, 'embedded', logs)
35
- elsif (dir + 'bolt.yaml').file? || (dir + CONFIG_NAME).file?
35
+ elsif (dir + 'bolt.yaml').file?
36
+ command = Bolt::Util.powershell? ? 'Update-BoltProject' : 'bolt project migrate'
37
+ msg = "Configuration file #{dir + 'bolt.yaml'} is deprecated and will be "\
38
+ "removed in Bolt 3.0.\nUpdate your Bolt project to the latest Bolt practices "\
39
+ "using #{command}"
40
+ deprecations << { type: "Project level bolt.yaml",
41
+ msg: msg }
42
+ create_project(dir, 'local', logs, deprecations)
43
+ elsif (dir + CONFIG_NAME).file?
36
44
  create_project(dir, 'local', logs)
37
45
  elsif dir.root?
38
46
  default_project(logs)
39
47
  else
40
48
  logs << { debug: "Did not detect Boltdir, bolt.yaml, or bolt-project.yaml at '#{dir}'. "\
41
49
  "This directory won't be loaded as a project." }
42
- find_boltdir(dir.parent, logs)
50
+ find_boltdir(dir.parent, logs, deprecations)
43
51
  end
44
52
  end
45
53
 
46
- def self.create_project(path, type = 'option', logs = [])
54
+ def self.create_project(path, type = 'option', logs = [], deprecations = [])
47
55
  fullpath = Pathname.new(path).expand_path
48
56
 
49
57
  if type == 'user'
@@ -72,15 +80,10 @@ module Bolt
72
80
  data = Bolt::Util.read_optional_yaml_hash(File.expand_path(project_file), 'project')
73
81
  default = type =~ /user|system/ ? 'default ' : ''
74
82
  exist = File.exist?(File.expand_path(project_file))
75
- deprecations = []
76
83
 
77
84
  logs << { info: "Loaded #{default}project from '#{fullpath}'" } if exist
78
85
 
79
- # Validate the config against the schema. This will raise a single error
80
- # with all validation errors.
81
- schema = Bolt::Config::OPTIONS.slice(*Bolt::Config::BOLT_PROJECT_OPTIONS)
82
-
83
- Bolt::Config::Validator.new.tap do |validator|
86
+ Bolt::Validator.new.tap do |validator|
84
87
  validator.validate(data, schema, project_file)
85
88
 
86
89
  validator.warnings.each { |warning| logs << { warn: warning } }
@@ -93,6 +96,16 @@ module Bolt
93
96
  new(data, path, type, logs, deprecations)
94
97
  end
95
98
 
99
+ # Builds the schema for bolt-project.yaml used by the validator.
100
+ #
101
+ def self.schema
102
+ {
103
+ type: Hash,
104
+ properties: Bolt::Config::BOLT_PROJECT_OPTIONS.map { |opt| [opt, _ref: opt] }.to_h,
105
+ definitions: Bolt::Config::OPTIONS
106
+ }
107
+ end
108
+
96
109
  def initialize(raw_data, path, type = 'option', logs = [], deprecations = [])
97
110
  @path = Pathname.new(path).expand_path
98
111
  @project_file = @path + CONFIG_NAME
@@ -116,6 +129,7 @@ module Bolt
116
129
  @plans_path = @path + 'plans'
117
130
  @managed_moduledir = @path + '.modules'
118
131
  @backup_dir = @path + '.bolt-bak'
132
+ @cache_file = @path + '.plugin_cache.json'
119
133
 
120
134
  tc = Bolt::Config::INVENTORY_OPTIONS.keys & raw_data.keys
121
135
  if tc.any?
@@ -184,6 +198,14 @@ module Bolt
184
198
  @data['plans']
185
199
  end
186
200
 
201
+ def plugin_cache
202
+ @data['plugin-cache']
203
+ end
204
+
205
+ def module_install
206
+ @data['module-install']
207
+ end
208
+
187
209
  def modules
188
210
  @modules ||= @data['modules']&.map do |mod|
189
211
  if mod.is_a?(String)
@@ -205,7 +227,10 @@ module Bolt
205
227
  raise Bolt::ValidationError, "The project '#{name}' will not be loaded. The project name conflicts "\
206
228
  "with a built-in Bolt module of the same name."
207
229
  end
208
- else
230
+ elsif name.nil? &&
231
+ (File.directory?(plans_path) ||
232
+ File.directory?(@path + 'tasks') ||
233
+ File.directory?(@path + 'files'))
209
234
  message = "No project name is specified in bolt-project.yaml. Project-level content will not be available."
210
235
  @logs << { warn: message }
211
236
  end