puppet-debugger 0.4.0

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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/.document +5 -0
  3. data/.gitignore +54 -0
  4. data/.gitlab-ci.yml +129 -0
  5. data/.rspec +3 -0
  6. data/CHANGELOG.md +61 -0
  7. data/Gemfile +18 -0
  8. data/Gemfile.lock +67 -0
  9. data/LICENSE.txt +20 -0
  10. data/README.md +276 -0
  11. data/Rakefile +32 -0
  12. data/bin/pdb +4 -0
  13. data/lib/awesome_print/ext/awesome_puppet.rb +40 -0
  14. data/lib/puppet-debugger/cli.rb +247 -0
  15. data/lib/puppet-debugger/code/code_file.rb +98 -0
  16. data/lib/puppet-debugger/code/code_range.rb +69 -0
  17. data/lib/puppet-debugger/code/loc.rb +80 -0
  18. data/lib/puppet-debugger/debugger_code.rb +318 -0
  19. data/lib/puppet-debugger/support/compiler.rb +20 -0
  20. data/lib/puppet-debugger/support/environment.rb +38 -0
  21. data/lib/puppet-debugger/support/errors.rb +75 -0
  22. data/lib/puppet-debugger/support/facts.rb +78 -0
  23. data/lib/puppet-debugger/support/functions.rb +72 -0
  24. data/lib/puppet-debugger/support/input_responders.rb +136 -0
  25. data/lib/puppet-debugger/support/node.rb +90 -0
  26. data/lib/puppet-debugger/support/play.rb +91 -0
  27. data/lib/puppet-debugger/support/scope.rb +42 -0
  28. data/lib/puppet-debugger/support.rb +176 -0
  29. data/lib/puppet-debugger.rb +55 -0
  30. data/lib/trollop.rb +861 -0
  31. data/lib/version.rb +3 -0
  32. data/puppet-debugger.gemspec +36 -0
  33. data/run_container_test.sh +12 -0
  34. data/spec/facts_spec.rb +86 -0
  35. data/spec/fixtures/environments/production/manifests/site.pp +1 -0
  36. data/spec/fixtures/invalid_node_obj.yaml +8 -0
  37. data/spec/fixtures/node_obj.yaml +298 -0
  38. data/spec/fixtures/sample_manifest.pp +2 -0
  39. data/spec/fixtures/sample_start_debugger.pp +13 -0
  40. data/spec/pdb_spec.rb +50 -0
  41. data/spec/puppet-debugger_spec.rb +492 -0
  42. data/spec/remote_node_spec.rb +170 -0
  43. data/spec/spec_helper.rb +57 -0
  44. data/spec/support_spec.rb +190 -0
  45. data/test_matrix.rb +42 -0
  46. metadata +148 -0
@@ -0,0 +1,136 @@
1
+ module PuppetDebugger
2
+ module Support
3
+ module InputResponders
4
+
5
+ def static_responder_list
6
+ ["exit", "functions", "classification","vars", 'facterdb_filter', "krt", "facts",
7
+ "resources", "classes", "whereami", "play","reset", "help"
8
+ ]
9
+ end
10
+
11
+ # @source_file and @source_line_num instance variables must be set for this
12
+ # method to show the surrounding code
13
+ # @return [String] - string output of the code surrounded by the breakpoint or nil if file or line_num do not exist
14
+ def whereami(command=nil, args=nil)
15
+ file=@source_file
16
+ line_num=@source_line_num
17
+ if file and line_num
18
+ if file == :code
19
+ source_code = Puppet[:code]
20
+ code = DebuggerCode.from_string(source_code, :puppet)
21
+ else
22
+ code = DebuggerCode.from_file(file, :puppet)
23
+ end
24
+ return code.with_marker(line_num).around(line_num, 5).with_line_numbers.with_indentation(5).to_s
25
+ end
26
+ end
27
+
28
+ # displays the facterdb filter
29
+ # @param [Array] - args is not used
30
+ def facterdb_filter(args=[])
31
+ dynamic_facterdb_filter.ai
32
+ end
33
+
34
+ def help(args=[])
35
+ PuppetDebugger::Cli.print_repl_desc
36
+ end
37
+
38
+ def handle_set(input)
39
+ output = ''
40
+ args = input.split(' ')
41
+ args.shift # throw away the set
42
+ case args.shift
43
+ when /node/
44
+ if name = args.shift
45
+ output = "Resetting to use node #{name}"
46
+ reset
47
+ set_remote_node_name(name)
48
+ else
49
+ out_buffer.puts "Must supply a valid node name"
50
+ end
51
+ when /loglevel/
52
+ if level = args.shift
53
+ @log_level = level
54
+ set_log_level(level)
55
+ output = "loglevel #{Puppet::Util::Log.level} is set"
56
+ end
57
+ end
58
+ output
59
+ end
60
+
61
+ def facts(args=[])
62
+ variables = node.facts.values
63
+ variables.ai({:sort_keys => true, :indent => -1})
64
+ end
65
+
66
+ def functions(args=[])
67
+ filter = args.first || ''
68
+ function_map.keys.sort.grep(/^#{Regexp.escape(filter)}/)
69
+ end
70
+
71
+ def vars(args=[])
72
+ # remove duplicate variables that are also in the facts hash
73
+ variables = scope.to_hash.delete_if {| key, value | node.facts.values.key?(key) }
74
+ variables['facts'] = 'removed by the puppet-debugger' if variables.key?('facts')
75
+ output = "Facts were removed for easier viewing".ai + "\n"
76
+ output += variables.ai({:sort_keys => true, :indent => -1})
77
+ end
78
+
79
+ def environment(args=[])
80
+ "Puppet Environment: #{puppet_env_name}"
81
+ end
82
+
83
+ def reset(args=[])
84
+ set_scope(nil)
85
+ set_remote_node_name(nil)
86
+ set_node(nil)
87
+ set_facts(nil)
88
+ set_environment(nil)
89
+ set_log_level(log_level)
90
+ end
91
+
92
+ def set_log_level(level)
93
+ Puppet::Util::Log.level = level.to_sym
94
+ buffer_log = Puppet::Util::Log.newdestination(:buffer)
95
+ if buffer_log
96
+ # if this is already set the buffer_log is nil
97
+ buffer_log.out_buffer = out_buffer
98
+ buffer_log.err_buffer = out_buffer
99
+ end
100
+ nil
101
+ end
102
+
103
+ def krt(args=[])
104
+ known_resource_types.ai({:sort_keys => true, :indent => -1})
105
+ end
106
+
107
+ def play(args=[])
108
+ config = {}
109
+ config[:play] = args.first
110
+ play_back(config)
111
+ return nil # we don't want to return anything
112
+ end
113
+
114
+ def classification(args=[])
115
+ node.classes.ai
116
+ end
117
+
118
+ def resources(args=[])
119
+ res = scope.compiler.catalog.resources.map do |res|
120
+ res.to_s.gsub(/\[/, "['").gsub(/\]/, "']") # ensure the title has quotes
121
+ end
122
+ if !args.first.nil?
123
+ res[args.first.to_i].ai
124
+ else
125
+ output = "Resources not shown in any specific order\n".warning
126
+ output += res.ai
127
+ end
128
+ end
129
+
130
+ def classes(args=[])
131
+ scope.compiler.catalog.classes.ai
132
+ end
133
+
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,90 @@
1
+ require 'puppet/indirector/node/rest'
2
+
3
+ module PuppetDebugger
4
+ module Support
5
+ module Node
6
+ # creates a node object using defaults or gets the remote node
7
+ # object if the remote_node_name is defined
8
+ def create_node
9
+ Puppet[:trusted_server_facts] = true if Puppet.version.to_f >= 4.1
10
+
11
+ if remote_node_name
12
+ # refetch
13
+ node_obj = set_node_from_name(remote_node_name)
14
+ end
15
+ unless node_obj
16
+ options = {}
17
+ options[:parameters] = default_facts.values
18
+ options[:facts] = default_facts
19
+ options[:classes] = []
20
+ options[:environment] = puppet_environment
21
+ name = default_facts.values['fqdn']
22
+ node_obj = Puppet::Node.new(name, options)
23
+ node_obj.add_server_facts(server_facts) if node_obj.respond_to?(:add_server_facts)
24
+ node_obj
25
+ end
26
+ node_obj
27
+ end
28
+
29
+ def set_remote_node_name(name)
30
+ @remote_node_name = name
31
+ end
32
+
33
+ def remote_node_name=(name)
34
+ @remote_node_name = name
35
+ end
36
+
37
+ def remote_node_name
38
+ @remote_node_name
39
+ end
40
+
41
+ # @return [node] puppet node object
42
+ def node
43
+ @node ||= create_node
44
+ end
45
+
46
+ def get_remote_node(name)
47
+ indirection = Puppet::Indirector::Indirection.instance(:node)
48
+ indirection.terminus_class = 'rest'
49
+ remote_node = indirection.find(name, :environment => puppet_environment)
50
+ remote_node
51
+ end
52
+
53
+ # this is a hack to get around that the puppet node fact face does not return
54
+ # a proper node object with the facts hash populated
55
+ # returns a node object with a proper facts hash
56
+ def convert_remote_node(remote_node)
57
+ options = {}
58
+ # remove trusted data as it will later get populated during compilation
59
+ parameters = remote_node.parameters.dup
60
+ trusted_data = parameters.delete('trusted')
61
+ options[:parameters] = parameters || {}
62
+ options[:facts] = Puppet::Node::Facts.new(remote_node.name,remote_node.parameters)
63
+ options[:classes] = remote_node.classes
64
+ options[:environment] = puppet_environment
65
+ node_object = Puppet::Node.new(remote_node.name, options)
66
+ node_object.add_server_facts(server_facts) if node_object.respond_to?(:add_server_facts)
67
+ node_object.trusted_data = trusted_data
68
+ node_object
69
+ end
70
+
71
+ # query the remote puppet server and retrieve the node object
72
+ #
73
+ def set_node_from_name(name)
74
+ out_buffer.puts ("Fetching node #{name}")
75
+ remote_node = get_remote_node(name)
76
+ if remote_node and remote_node.parameters.empty?
77
+ remote_node_name = nil # clear out the remote name
78
+ raise PuppetDebugger::Exception::UndefinedNode.new(:name => remote_node.name)
79
+ end
80
+ remote_node_name = remote_node.name
81
+ node_object = convert_remote_node(remote_node)
82
+ set_node(node_object)
83
+ end
84
+
85
+ def set_node(value)
86
+ @node = value
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,91 @@
1
+ module PuppetDebugger
2
+ module Support
3
+ module Play
4
+
5
+ def play_back(config={})
6
+ if config[:play]
7
+ if config[:play] =~ /^http/
8
+ play_back_url(config[:play])
9
+ elsif File.exists? config[:play]
10
+ play_back_string(File.read(config[:play]))
11
+ else config[:play]
12
+ out_buffer.puts "puppet-debugger can't play #{config[:play]}'"
13
+ end
14
+ end
15
+ end
16
+
17
+ def convert_to_text(url)
18
+ require 'uri'
19
+ url_data = URI(url)
20
+ case url_data.host
21
+ when /^gist\.github*/
22
+ unless url_data.path =~ /raw/
23
+ url = url += '.txt'
24
+ end
25
+ url
26
+ when /^github.com/
27
+ if url_data.path =~ /blob/
28
+ url.gsub('blob', 'raw')
29
+ end
30
+ when /^gist.github.com/
31
+ unless url_data.path =~ /raw/
32
+ url = url += '.txt'
33
+ end
34
+ url
35
+ when /^gitlab.com/
36
+ if url_data.path =~ /snippets/
37
+ url += '/raw' unless url_data.path =~ /raw/
38
+ url
39
+ else
40
+ url.gsub('blob', 'raw')
41
+ end
42
+ else
43
+ url
44
+ end
45
+ end
46
+
47
+ # opens the url and reads the data
48
+ def fetch_url_data(url)
49
+ open(url).read
50
+ end
51
+
52
+ def play_back_url(url)
53
+ begin
54
+ require 'open-uri'
55
+ require 'net/http'
56
+ converted_url = convert_to_text(url)
57
+ str = fetch_url_data(converted_url)
58
+ play_back_string(str)
59
+ rescue SocketError
60
+ abort "puppet-debugger can't play `#{converted_url}'"
61
+ end
62
+ end
63
+
64
+ # plays back the string to the output stream
65
+ # puts the input to the output as well as the produced output
66
+ def play_back_string(str)
67
+ full_buffer = ''
68
+ str.split("\n").each do |buf|
69
+ begin
70
+ full_buffer += buf
71
+ # unless this is puppet code, otherwise skip repl keywords
72
+ if keyword_expression.match(buf)
73
+ out_buffer.write(">> ")
74
+ else
75
+ parser.parse_string(full_buffer)
76
+ out_buffer.write(">> ")
77
+ end
78
+ rescue Puppet::ParseErrorWithIssue => e
79
+ if multiline_input?(e)
80
+ full_buffer += "\n"
81
+ next
82
+ end
83
+ end
84
+ out_buffer.puts(full_buffer)
85
+ handle_input(full_buffer)
86
+ full_buffer = ''
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,42 @@
1
+ module PuppetDebugger
2
+ module Support
3
+ module Scope
4
+ def set_scope(value)
5
+ @scope = value
6
+ end
7
+
8
+ # @return [Scope] puppet scope object
9
+ def scope
10
+ unless @scope
11
+ @scope ||= create_scope
12
+ end
13
+ @scope
14
+ end
15
+
16
+ def create_scope
17
+ do_initialize
18
+ begin
19
+ @compiler = create_compiler(node) # creates a new compiler for each scope
20
+ scope = Puppet::Parser::Scope.new(@compiler)
21
+ # creates a node class
22
+ scope.source = Puppet::Resource::Type.new(:node, node.name)
23
+ scope.parent = @compiler.topscope
24
+ load_lib_dirs
25
+ # compiling will load all the facts into the scope
26
+ # without this step facts will not get resolved
27
+ scope.compiler.compile # this will load everything into the scope
28
+ rescue StandardError => e
29
+ err = parse_error(e)
30
+ raise err
31
+ end
32
+ scope
33
+ end
34
+
35
+ # returns a hash of varaibles that are currently in scope
36
+ def scope_vars
37
+ vars = scope.to_hash.delete_if {| key, value | node.facts.values.key?(key.to_sym) }
38
+ vars['facts'] = 'removed by the puppet-debugger'
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,176 @@
1
+ require 'puppet/pops'
2
+ require 'facterdb'
3
+ require 'tempfile'
4
+
5
+ # load all the generators found in the generators directory
6
+ Dir.glob(File.join(File.dirname(__FILE__),'support', '*.rb')).each do |file|
7
+ require_relative File.join('support', File.basename(file, '.rb'))
8
+ end
9
+
10
+ module PuppetDebugger
11
+ module Support
12
+ include PuppetDebugger::Support::Compilier
13
+ include PuppetDebugger::Support::Environment
14
+ include PuppetDebugger::Support::Facts
15
+ include PuppetDebugger::Support::Scope
16
+ include PuppetDebugger::Support::Functions
17
+ include PuppetDebugger::Support::Node
18
+ include PuppetDebugger::Support::InputResponders
19
+ include PuppetDebugger::Support::Play
20
+
21
+ # parses the error type into a more useful error message defined in errors.rb
22
+ # returns new error object or the original if error cannot be parsed
23
+ def parse_error(error)
24
+ case error
25
+ when SocketError
26
+ PuppetDebugger::Exception::ConnectError.new(:message => "Unknown host: #{Puppet[:server]}")
27
+ when Net::HTTPError
28
+ PuppetDebugger::Exception::AuthError.new(:message => error.message)
29
+ when Errno::ECONNREFUSED
30
+ PuppetDebugger::Exception::ConnectError.new(:message => error.message)
31
+ when Puppet::Error
32
+ if error.message =~ /could\ not\ find\ class/i
33
+ PuppetDebugger::Exception::NoClassError.new(:default_modules_paths => default_modules_paths,
34
+ :message => error.message)
35
+ elsif error.message =~ /default\ node/i
36
+ PuppetDebugger::Exception::NodeDefinitionError.new(:default_site_manifest => default_site_manifest,
37
+ :message => error.message)
38
+ else
39
+ error
40
+ end
41
+ else
42
+ error
43
+ end
44
+ end
45
+
46
+ # returns an array of module directories, generally this is the only place
47
+ # to look for puppet code by default. This is read from the puppet configuration
48
+ def default_modules_paths
49
+ dirs = []
50
+ do_initialize if Puppet[:codedir].nil?
51
+ # add the puppet-debugger directory so we can load any defined functions
52
+ dirs << File.join(Puppet[:environmentpath],default_puppet_env_name,'modules') unless Puppet[:environmentpath].empty?
53
+ dirs << Puppet.settings[:basemodulepath].split(':')
54
+ dirs.flatten
55
+ end
56
+
57
+ # this is the lib directory of this gem
58
+ # in order to load any puppet functions from this gem we need to add the lib path
59
+ # of this gem
60
+ def puppet_repl_lib_dir
61
+ File.expand_path(File.join(File.dirname(File.dirname(File.dirname(__FILE__))), 'lib'))
62
+ end
63
+
64
+ # returns all the modules paths defined in the environment
65
+ def modules_paths
66
+ puppet_environment.full_modulepath
67
+ end
68
+
69
+ def initialize_from_scope(value)
70
+ set_scope(value)
71
+ unless value.nil?
72
+ set_environment(value.environment)
73
+ set_node(value.compiler.node)
74
+ set_compiler(value.compiler)
75
+ end
76
+ end
77
+
78
+ def keyword_expression
79
+ @keyword_expression ||= Regexp.new(/^exit|^:set|^play|^classification|^facts|^vars|^functions|^classes|^resources|^krt|^environment|^reset|^help/)
80
+ end
81
+
82
+ def known_resource_types
83
+ res = {
84
+ :hostclasses => scope.environment.known_resource_types.hostclasses.keys,
85
+ :definitions => scope.environment.known_resource_types.definitions.keys,
86
+ :nodes => scope.environment.known_resource_types.nodes.keys,
87
+ }
88
+ if sites = scope.environment.known_resource_types.instance_variable_get(:@sites)
89
+ res.merge!(:sites => scope.environment.known_resource_types.instance_variable_get(:@sites).first)
90
+ end
91
+ if scope.environment.known_resource_types.respond_to?(:applications)
92
+ res.merge!(:applications => scope.environment.known_resource_types.applications.keys)
93
+ end
94
+ # some versions of puppet do not support capabilities
95
+ if scope.environment.known_resource_types.respond_to?(:capability_mappings)
96
+ res.merge!(:capability_mappings => scope.environment.known_resource_types.capability_mappings.keys)
97
+ end
98
+ res
99
+ end
100
+
101
+ # this is required in order to load things only when we need them
102
+ def do_initialize
103
+ begin
104
+ Puppet.initialize_settings
105
+ Puppet[:parser] = 'future' # this is required in order to work with puppet 3.8
106
+ Puppet[:trusted_node_data] = true
107
+ rescue ArgumentError => e
108
+
109
+ rescue Puppet::DevError => e
110
+ # do nothing otherwise calling init twice raises an error
111
+ end
112
+ end
113
+
114
+ # @param String - any valid puppet language code
115
+ # @return Hostclass - a puppet Program object which is considered the main class
116
+ def generate_ast(string = nil)
117
+ parse_result = parser.parse_string(string, '')
118
+ # the parse_result may be
119
+ # * empty / nil (no input)
120
+ # * a Model::Program
121
+ # * a Model::Expression
122
+ #
123
+ model = parse_result.nil? ? nil : parse_result.current
124
+ args = {}
125
+ ::Puppet::Pops::Model::AstTransformer.new('').merge_location(args, model)
126
+
127
+ ast_code =
128
+ if model.is_a? ::Puppet::Pops::Model::Program
129
+ ::Puppet::Parser::AST::PopsBridge::Program.new(model, args)
130
+ else
131
+ args[:value] = model
132
+ ::Puppet::Parser::AST::PopsBridge::Expression.new(args)
133
+ end
134
+ # Create the "main" class for the content - this content will get merged with all other "main" content
135
+ ::Puppet::Parser::AST::Hostclass.new('', :code => ast_code)
136
+ end
137
+
138
+ # @param String - any valid puppet language code
139
+ # @return Object - returns either a string of the result or object from puppet evaulation
140
+ def puppet_eval(input)
141
+ # in order to add functions to the scope the loaders must be created
142
+ # in order to call native functions we need to set the global_scope
143
+ ast = generate_ast(input)
144
+ # record the input for puppet to retrieve and reference later
145
+ file = Tempfile.new(['puppet_repl_input', '.pp'])
146
+ File.open(file, 'w') do |f|
147
+ f.write(input)
148
+ end
149
+ Puppet.override( {:code => input, :global_scope => scope, :loaders => scope.compiler.loaders } , 'For puppet-debugger') do
150
+ # because the repl is not a module we leave the modname blank
151
+ scope.environment.known_resource_types.import_ast(ast, '')
152
+ parser.evaluate_string(scope, input, File.expand_path(file))
153
+ end
154
+ end
155
+
156
+ def puppet_lib_dir
157
+ # returns something like "/Library/Ruby/Gems/2.0.0/gems/puppet-4.2.2/lib/puppet.rb"
158
+ # this is only useful when returning a namespace with the functions
159
+ @puppet_lib_dir ||= File.dirname(Puppet.method(:[]).source_location.first)
160
+ end
161
+
162
+ # returns a future parser for evaluating code
163
+ def parser
164
+ @parser ||= ::Puppet::Pops::Parser::EvaluatingParser.new
165
+ end
166
+
167
+ def default_manifests_dir
168
+ File.join(Puppet[:environmentpath],default_puppet_env_name,'manifests')
169
+ end
170
+
171
+ def default_site_manifest
172
+ File.join(default_manifests_dir, 'site.pp')
173
+ end
174
+
175
+ end
176
+ end
@@ -0,0 +1,55 @@
1
+ require_relative 'puppet-debugger/cli'
2
+ require_relative 'version'
3
+ require 'awesome_print'
4
+ require_relative 'awesome_print/ext/awesome_puppet'
5
+ require_relative 'trollop'
6
+ require 'puppet/util/log'
7
+ require_relative 'puppet-debugger/debugger_code'
8
+
9
+ require_relative 'puppet-debugger/support/errors'
10
+ # monkey patch in some color effects string methods
11
+ class String
12
+ def red; "\033[31m#{self}\033[0m" end
13
+ def green; "\033[32m#{self}\033[0m" end
14
+ def cyan; "\033[36m#{self}\033[0m" end
15
+ def yellow; "\033[33m#{self}\033[0m" end
16
+ def warning; yellow end
17
+ def fatal; red end
18
+ def info; green end
19
+
20
+ def camel_case
21
+ return self if self !~ /_/ && self =~ /[A-Z]+.*/
22
+ split('_').map(&:capitalize).join
23
+ end
24
+ end
25
+
26
+ Puppet::Util::Log.newdesttype :buffer do
27
+ require 'puppet/util/colors'
28
+ include Puppet::Util::Colors
29
+
30
+ attr_accessor :err_buffer, :out_buffer
31
+
32
+ def initialize(err=$stderr, out=$stdout)
33
+ @err_buffer = err
34
+ @out_buffer = out
35
+ end
36
+
37
+ def handle(msg)
38
+ levels = {
39
+ :emerg => { :name => 'Emergency', :color => :hred, :stream => err_buffer },
40
+ :alert => { :name => 'Alert', :color => :hred, :stream => err_buffer },
41
+ :crit => { :name => 'Critical', :color => :hred, :stream => err_buffer },
42
+ :err => { :name => 'Error', :color => :hred, :stream => err_buffer },
43
+ :warning => { :name => 'Warning', :color => :hred, :stream => err_buffer },
44
+ :notice => { :name => 'Notice', :color => :reset, :stream => out_buffer },
45
+ :info => { :name => 'Info', :color => :green, :stream => out_buffer },
46
+ :debug => { :name => 'Debug', :color => :cyan, :stream => out_buffer },
47
+ }
48
+
49
+ str = msg.respond_to?(:multiline) ? msg.multiline : msg.to_s
50
+ str = msg.source == "Puppet" ? str : "#{msg.source}: #{str}"
51
+
52
+ level = levels[msg.level]
53
+ level[:stream].puts colorize(level[:color], "#{level[:name]}: #{str}")
54
+ end
55
+ end