turbot 0.0.2

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 (71) hide show
  1. checksums.yaml +15 -0
  2. data/README.md +36 -0
  3. data/bin/turbot +17 -0
  4. data/data/cacert.pem +3988 -0
  5. data/lib/turbot/auth.rb +315 -0
  6. data/lib/turbot/cli.rb +38 -0
  7. data/lib/turbot/client/cisaurus.rb +25 -0
  8. data/lib/turbot/client/pgbackups.rb +113 -0
  9. data/lib/turbot/client/rendezvous.rb +111 -0
  10. data/lib/turbot/client/ssl_endpoint.rb +25 -0
  11. data/lib/turbot/client/turbot_postgresql.rb +148 -0
  12. data/lib/turbot/client.rb +757 -0
  13. data/lib/turbot/command/auth.rb +85 -0
  14. data/lib/turbot/command/base.rb +192 -0
  15. data/lib/turbot/command/bots.rb +326 -0
  16. data/lib/turbot/command/config.rb +123 -0
  17. data/lib/turbot/command/help.rb +179 -0
  18. data/lib/turbot/command/keys.rb +115 -0
  19. data/lib/turbot/command/logs.rb +34 -0
  20. data/lib/turbot/command/ssl.rb +43 -0
  21. data/lib/turbot/command/status.rb +51 -0
  22. data/lib/turbot/command/update.rb +47 -0
  23. data/lib/turbot/command/version.rb +23 -0
  24. data/lib/turbot/command.rb +304 -0
  25. data/lib/turbot/deprecated/help.rb +38 -0
  26. data/lib/turbot/deprecated.rb +5 -0
  27. data/lib/turbot/distribution.rb +9 -0
  28. data/lib/turbot/errors.rb +28 -0
  29. data/lib/turbot/excon.rb +11 -0
  30. data/lib/turbot/helpers/log_displayer.rb +70 -0
  31. data/lib/turbot/helpers/pg_dump_restore.rb +115 -0
  32. data/lib/turbot/helpers/turbot_postgresql.rb +213 -0
  33. data/lib/turbot/helpers.rb +521 -0
  34. data/lib/turbot/plugin.rb +165 -0
  35. data/lib/turbot/updater.rb +171 -0
  36. data/lib/turbot/version.rb +3 -0
  37. data/lib/turbot.rb +19 -0
  38. data/lib/vendor/turbot/okjson.rb +598 -0
  39. data/spec/helper/legacy_help.rb +16 -0
  40. data/spec/helper/pg_dump_restore_spec.rb +67 -0
  41. data/spec/schemas/dummy_schema.json +12 -0
  42. data/spec/spec.opts +1 -0
  43. data/spec/spec_helper.rb +220 -0
  44. data/spec/support/display_message_matcher.rb +49 -0
  45. data/spec/support/dummy_api.rb +120 -0
  46. data/spec/support/openssl_mock_helper.rb +8 -0
  47. data/spec/support/organizations_mock_helper.rb +11 -0
  48. data/spec/turbot/auth_spec.rb +214 -0
  49. data/spec/turbot/client/pgbackups_spec.rb +43 -0
  50. data/spec/turbot/client/rendezvous_spec.rb +62 -0
  51. data/spec/turbot/client/ssl_endpoint_spec.rb +48 -0
  52. data/spec/turbot/client/turbot_postgresql_spec.rb +71 -0
  53. data/spec/turbot/client_spec.rb +548 -0
  54. data/spec/turbot/command/auth_spec.rb +38 -0
  55. data/spec/turbot/command/base_spec.rb +66 -0
  56. data/spec/turbot/command/bots_spec.rb +54 -0
  57. data/spec/turbot/command/config_spec.rb +143 -0
  58. data/spec/turbot/command/help_spec.rb +90 -0
  59. data/spec/turbot/command/keys_spec.rb +117 -0
  60. data/spec/turbot/command/logs_spec.rb +60 -0
  61. data/spec/turbot/command/status_spec.rb +48 -0
  62. data/spec/turbot/command/version_spec.rb +16 -0
  63. data/spec/turbot/command_spec.rb +131 -0
  64. data/spec/turbot/helpers/turbot_postgresql_spec.rb +181 -0
  65. data/spec/turbot/helpers_spec.rb +48 -0
  66. data/spec/turbot/plugin_spec.rb +172 -0
  67. data/spec/turbot/updater_spec.rb +44 -0
  68. data/templates/manifest.json +7 -0
  69. data/templates/scraper.py +5 -0
  70. data/templates/scraper.rb +6 -0
  71. metadata +199 -0
@@ -0,0 +1,304 @@
1
+ require 'turbot/helpers'
2
+ require 'turbot/plugin'
3
+ require 'turbot/version'
4
+ require "optparse"
5
+ require 'excon'
6
+
7
+ module Turbot
8
+ module Command
9
+ class CommandFailed < RuntimeError; end
10
+
11
+ extend Turbot::Helpers
12
+
13
+ def self.load
14
+ Dir[File.join(File.dirname(__FILE__), "command", "*.rb")].each do |file|
15
+ require file
16
+ end
17
+ Turbot::Plugin.load!
18
+ unregister_commands_made_private_after_the_fact
19
+ end
20
+
21
+ def self.commands
22
+ @@commands ||= {}
23
+ end
24
+
25
+ def self.command_aliases
26
+ @@command_aliases ||= {}
27
+ end
28
+
29
+ def self.files
30
+ @@files ||= Hash.new {|hash,key| hash[key] = File.readlines(key).map {|line| line.strip}}
31
+ end
32
+
33
+ def self.namespaces
34
+ @@namespaces ||= {}
35
+ end
36
+
37
+ def self.register_command(command)
38
+ commands[command[:command]] = command
39
+ end
40
+
41
+ def self.unregister_commands_made_private_after_the_fact
42
+ commands.values \
43
+ .select { |c| c[:klass].private_method_defined? c[:method] } \
44
+ .each { |c| commands.delete c[:command] }
45
+ end
46
+
47
+ def self.register_namespace(namespace)
48
+ namespaces[namespace[:name]] = namespace
49
+ end
50
+
51
+ def self.current_command
52
+ @current_command
53
+ end
54
+
55
+ def self.current_command=(new_current_command)
56
+ @current_command = new_current_command
57
+ end
58
+
59
+ def self.current_args
60
+ @current_args
61
+ end
62
+
63
+ def self.current_options
64
+ @current_options ||= {}
65
+ end
66
+
67
+ def self.global_options
68
+ @global_options ||= []
69
+ end
70
+
71
+ def self.invalid_arguments
72
+ @invalid_arguments
73
+ end
74
+
75
+ def self.shift_argument
76
+ # dup argument to get a non-frozen string
77
+ @invalid_arguments.shift.dup rescue nil
78
+ end
79
+
80
+ def self.validate_arguments!
81
+ unless invalid_arguments.empty?
82
+ arguments = invalid_arguments.map {|arg| "\"#{arg}\""}
83
+ if arguments.length == 1
84
+ message = "Invalid argument: #{arguments.first}"
85
+ elsif arguments.length > 1
86
+ message = "Invalid arguments: "
87
+ message << arguments[0...-1].join(", ")
88
+ message << " and "
89
+ message << arguments[-1]
90
+ end
91
+ $stderr.puts(format_with_bang(message))
92
+ run(current_command, ["--help"])
93
+ exit(1)
94
+ end
95
+ end
96
+
97
+ def self.warnings
98
+ @warnings ||= []
99
+ end
100
+
101
+ def self.display_warnings
102
+ unless warnings.empty?
103
+ $stderr.puts(warnings.map {|warning| " ! #{warning}"}.join("\n"))
104
+ end
105
+ end
106
+
107
+ def self.global_option(name, *args, &blk)
108
+ # args.sort.reverse gives -l, --long order
109
+ global_options << { :name => name.to_s, :args => args.sort.reverse, :proc => blk }
110
+ end
111
+
112
+ global_option :bot, "-b", "--bot APP" do |bot|
113
+ raise OptionParser::InvalidOption.new(bot) if bot == "pp"
114
+ end
115
+
116
+ global_option :org, "-o", "--org ORG" do |org|
117
+ raise OptionParser::InvalidOption.new(org) if org == "rg"
118
+ end
119
+ global_option :personal, "-p", "--personal"
120
+
121
+ global_option :confirm, "--confirm APP"
122
+ global_option :help, "-h", "--help"
123
+ global_option :remote, "-r", "--remote REMOTE"
124
+
125
+ def self.prepare_run(cmd, args=[])
126
+ command = parse(cmd)
127
+
128
+ if args.include?('-h') || args.include?('--help')
129
+ args.unshift(cmd) unless cmd =~ /^-.*/
130
+ cmd = 'help'
131
+ command = parse(cmd)
132
+ end
133
+
134
+ if cmd == '--version'
135
+ cmd = 'version'
136
+ command = parse(cmd)
137
+ end
138
+
139
+ @current_command = cmd
140
+ @anonymized_args, @normalized_args = [], []
141
+
142
+ opts = {}
143
+ invalid_options = []
144
+
145
+ parser = OptionParser.new do |parser|
146
+ # remove OptionParsers Officious['version'] to avoid conflicts
147
+ # see: https://github.com/ruby/ruby/blob/trunk/lib/optparse.rb#L814
148
+ parser.base.long.delete('version')
149
+ (global_options + (command && command[:options] || [])).each do |option|
150
+ parser.on(*option[:args]) do |value|
151
+ if option[:proc]
152
+ option[:proc].call(value)
153
+ end
154
+ opts[option[:name].gsub('-', '_').to_sym] = value
155
+ ARGV.join(' ') =~ /(#{option[:args].map {|arg| arg.split(' ', 2).first}.join('|')})/
156
+ @anonymized_args << "#{$1} _"
157
+ @normalized_args << "#{option[:args].last.split(' ', 2).first} _"
158
+ end
159
+ end
160
+ end
161
+
162
+ begin
163
+ parser.order!(args) do |nonopt|
164
+ invalid_options << nonopt
165
+ @anonymized_args << '!'
166
+ @normalized_args << '!'
167
+ end
168
+ rescue OptionParser::InvalidOption => ex
169
+ invalid_options << ex.args.first
170
+ @anonymized_args << '!'
171
+ @normalized_args << '!'
172
+ retry
173
+ end
174
+
175
+ args.concat(invalid_options)
176
+
177
+ @current_args = args
178
+ @current_options = opts
179
+ @invalid_arguments = invalid_options
180
+
181
+ @anonymous_command = [ARGV.first, *@anonymized_args].join(' ')
182
+ begin
183
+ usage_directory = "#{home_directory}/.turbot/usage"
184
+ FileUtils.mkdir_p(usage_directory)
185
+ usage_file = usage_directory << "/#{Turbot::VERSION}"
186
+ usage = if File.exists?(usage_file)
187
+ json_decode(File.read(usage_file))
188
+ else
189
+ {}
190
+ end
191
+ usage[@anonymous_command] ||= 0
192
+ usage[@anonymous_command] += 1
193
+ File.write(usage_file, json_encode(usage) + "\n")
194
+ rescue
195
+ # usage writing is not important, allow failures
196
+ end
197
+
198
+ if command
199
+ command_instance = command[:klass].new(args.dup, opts.dup)
200
+
201
+ if !@normalized_args.include?('--bot _') && (implied_bot = command_instance.bot rescue nil)
202
+ @normalized_args << '--bot _'
203
+ end
204
+ @normalized_command = [ARGV.first, @normalized_args.sort_by {|arg| arg.gsub('-', '')}].join(' ')
205
+
206
+ [ command_instance, command[:method] ]
207
+ else
208
+ error([
209
+ "`#{cmd}` is not a turbot command.",
210
+ suggestion(cmd, commands.keys + command_aliases.keys),
211
+ "See `turbot help` for a list of available commands."
212
+ ].compact.join("\n"))
213
+ end
214
+ end
215
+
216
+ def self.run(cmd, arguments=[])
217
+ begin
218
+ object, method = prepare_run(cmd, arguments.dup)
219
+ object.send(method)
220
+ rescue Interrupt, StandardError, SystemExit => error
221
+ # load likely error classes, as they may not be loaded yet due to defered loads
222
+ require 'rest_client'
223
+ raise(error)
224
+ end
225
+ rescue Turbot::API::Errors::Unauthorized, RestClient::Unauthorized
226
+ puts "Authentication failure"
227
+ if ENV['TURBOT_API_KEY']
228
+ exit 1
229
+ else
230
+ run "login"
231
+ retry
232
+ end
233
+ rescue Turbot::API::Errors::VerificationRequired, RestClient::PaymentRequired => e
234
+ retry if Turbot::Helpers.confirm_billing
235
+ rescue Turbot::API::Errors::NotFound => e
236
+ error extract_error(e.response.body) {
237
+ e.response.body =~ /^([\w\s]+ not found).?$/ ? $1 : "Resource not found"
238
+ }
239
+ rescue RestClient::ResourceNotFound => e
240
+ error extract_error(e.http_body) {
241
+ e.http_body =~ /^([\w\s]+ not found).?$/ ? $1 : "Resource not found"
242
+ }
243
+ rescue Turbot::API::Errors::Locked => e
244
+ bot = e.response.headers[:x_confirmation_required]
245
+ if confirm_command(bot, extract_error(e.response.body))
246
+ arguments << '--confirm' << bot
247
+ retry
248
+ end
249
+ rescue RestClient::Locked => e
250
+ bot = e.response.headers[:x_confirmation_required]
251
+ if confirm_command(bot, extract_error(e.http_body))
252
+ arguments << '--confirm' << bot
253
+ retry
254
+ end
255
+ rescue Turbot::API::Errors::Timeout, RestClient::RequestTimeout
256
+ error "API request timed out. Please try again, or contact support@turbot.com if this issue persists."
257
+ rescue Turbot::API::Errors::ErrorWithResponse => e
258
+ error extract_error(e.response.body)
259
+ rescue RestClient::RequestFailed => e
260
+ error extract_error(e.http_body)
261
+ rescue CommandFailed => e
262
+ error e.message
263
+ rescue OptionParser::ParseError
264
+ commands[cmd] ? run("help", [cmd]) : run("help")
265
+ rescue Excon::Errors::SocketError, SocketError => e
266
+ error("Unable to connect to Turbot API, please check internet connectivity and try again.")
267
+ ensure
268
+ display_warnings
269
+ end
270
+
271
+ def self.parse(cmd)
272
+ commands[cmd] || commands[command_aliases[cmd]]
273
+ end
274
+
275
+ def self.extract_error(body, options={})
276
+ default_error = block_given? ? yield : "Internal server error.\nRun `turbot status` to check for known platform issues."
277
+ parse_error_xml(body) || parse_error_json(body) || parse_error_plain(body) || default_error
278
+ end
279
+
280
+ def self.parse_error_xml(body)
281
+ xml_errors = REXML::Document.new(body).elements.to_a("//errors/error")
282
+ msg = xml_errors.map { |a| a.text }.join(" / ")
283
+ return msg unless msg.empty?
284
+ rescue Exception
285
+ end
286
+
287
+ def self.parse_error_json(body)
288
+ json = json_decode(body.to_s) rescue false
289
+ case json
290
+ when Array
291
+ json.first.join(' ') # message like [['base', 'message']]
292
+ when Hash
293
+ json['error'] || json['error_message'] || json['message'] # message like {'error' => 'message'}
294
+ else
295
+ nil
296
+ end
297
+ end
298
+
299
+ def self.parse_error_plain(body)
300
+ return unless body.respond_to?(:headers) && body.headers[:content_type].to_s.include?("text/plain")
301
+ body.to_s
302
+ end
303
+ end
304
+ end
@@ -0,0 +1,38 @@
1
+ require "turbot/deprecated"
2
+
3
+ module Turbot::Deprecated::Help
4
+ def self.included(base)
5
+ base.extend ClassMethods
6
+ end
7
+
8
+ class HelpGroup < Array
9
+ attr_reader :title
10
+
11
+ def initialize(title)
12
+ @title = title
13
+ end
14
+
15
+ def command(name, description)
16
+ self << [name, description]
17
+ end
18
+
19
+ def space
20
+ self << ['', '']
21
+ end
22
+ end
23
+
24
+ module ClassMethods
25
+ def groups
26
+ @groups ||= []
27
+ end
28
+
29
+ def group(title, &block)
30
+ groups << begin
31
+ group = HelpGroup.new(title)
32
+ yield group
33
+ group
34
+ end
35
+ end
36
+ end
37
+ end
38
+
@@ -0,0 +1,5 @@
1
+ require "turbot"
2
+
3
+ module Turbot::Deprecated
4
+ end
5
+
@@ -0,0 +1,9 @@
1
+ module Turbot
2
+ module Distribution
3
+ def self.files
4
+ Dir[File.expand_path("../../../{bin,data,lib}/**/*", __FILE__)].select do |file|
5
+ File.file?(file)
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,28 @@
1
+ module Turbot
2
+ class API
3
+ module Errors
4
+ class Error < StandardError; end
5
+
6
+ class ErrorWithResponse < Error
7
+ attr_reader :response
8
+
9
+ def initialize(message, response=nil)
10
+ message = message << "\nbody: #{response.body.inspect}" if response
11
+ super message
12
+ @response = response
13
+ end
14
+ end
15
+
16
+ class Unauthorized < ErrorWithResponse; end
17
+ class VerificationRequired < ErrorWithResponse; end
18
+ class Forbidden < ErrorWithResponse; end
19
+ class NotFound < ErrorWithResponse; end
20
+ class Timeout < ErrorWithResponse; end
21
+ class Locked < ErrorWithResponse; end
22
+ class RateLimitExceeded < ErrorWithResponse; end
23
+ class RequestFailed < ErrorWithResponse; end
24
+ class NilApp < ErrorWithResponse; end
25
+ class MissingManifest < ErrorWithResponse; end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,11 @@
1
+ module Excon
2
+
3
+ def self.get_with_redirect(url, options={})
4
+ res = Excon.get(url, options)
5
+ if [301, 302].include?(res.status)
6
+ return self.get_with_redirect(res.headers["Location"], options)
7
+ end
8
+ res
9
+ end
10
+
11
+ end
@@ -0,0 +1,70 @@
1
+ require "turbot/helpers"
2
+
3
+ module Turbot::Helpers
4
+ class LogDisplayer
5
+
6
+ include Turbot::Helpers
7
+
8
+ attr_reader :api, :bot, :opts
9
+
10
+ def initialize(api, bot, opts)
11
+ @api, @bot, @opts = api, bot, opts
12
+ end
13
+
14
+ def display_logs
15
+ @assigned_colors = {}
16
+ @line_start = true
17
+ @token = nil
18
+
19
+ api.read_logs(bot, opts).each do |chunk|
20
+ unless chunk.empty?
21
+ if STDOUT.isatty && ENV.has_key?("TERM")
22
+ display(colorize(chunk))
23
+ else
24
+ display(chunk)
25
+ end
26
+ end
27
+ end
28
+ rescue Errno::EPIPE
29
+ rescue Interrupt => interrupt
30
+ if STDOUT.isatty && ENV.has_key?("TERM")
31
+ display("\e[0m")
32
+ end
33
+ raise(interrupt)
34
+ end
35
+
36
+ COLORS = %w( cyan yellow green magenta red )
37
+ COLOR_CODES = {
38
+ "red" => 31,
39
+ "green" => 32,
40
+ "yellow" => 33,
41
+ "magenta" => 35,
42
+ "cyan" => 36,
43
+ }
44
+
45
+ def colorize(chunk)
46
+ lines = []
47
+ chunk.split("\n").map do |line|
48
+ if parsed_line = parse_log(line)
49
+ header, identifier, body = parsed_line
50
+ @assigned_colors[identifier] ||= COLORS[@assigned_colors.size % COLORS.size]
51
+ lines << [
52
+ "\e[#{COLOR_CODES[@assigned_colors[identifier]]}m",
53
+ header,
54
+ "\e[0m",
55
+ body,
56
+ ].join("")
57
+ elsif not line.empty?
58
+ lines << line
59
+ end
60
+ end
61
+ lines.join("\n")
62
+ end
63
+
64
+ def parse_log(log)
65
+ return unless parsed = log.match(/^(.*?\[([\w-]+)([\d\.]+)?\]:)(.*)?$/)
66
+ [1, 2, 4].map { |i| parsed[i] }
67
+ end
68
+
69
+ end
70
+ end
@@ -0,0 +1,115 @@
1
+ require 'uri'
2
+ class PgDumpRestore
3
+ attr_reader :command
4
+
5
+ def initialize(source, target, command)
6
+ @source = URI.parse(source)
7
+ @target = URI.parse(target)
8
+ @command = command
9
+
10
+ fill_in_shorthand_uris!
11
+ end
12
+
13
+ def execute
14
+ prepare
15
+ run
16
+ verify
17
+ end
18
+
19
+ def prepare
20
+ if @target.host == 'localhost'
21
+ create_local_db
22
+ else
23
+ ensure_remote_db_empty
24
+ end
25
+ end
26
+
27
+ def verify
28
+ verify_extensions_match
29
+ end
30
+
31
+ def dump_restore_cmd
32
+ pg_restore = gen_pg_restore_command(@target)
33
+ pg_dump = gen_pg_dump_command(@source)
34
+ "#{pg_dump} | #{pg_restore}"
35
+ end
36
+
37
+ private
38
+
39
+ def create_local_db
40
+ dbname = @target.path[1..-1]
41
+ cdb_output = `createdb #{dbname} 2>&1`
42
+ if $?.exitstatus != 0
43
+ if cdb_output =~ /already exists/
44
+ command.error(cdb_output + "\nPlease drop the local database (`dropdb #{dbname}`) and try again.")
45
+ else
46
+ command.error(cdb_output + "\nUnable to create new local database. Ensure your local Postgres is working and try again.")
47
+ end
48
+ end
49
+ end
50
+
51
+ def ensure_remote_db_empty
52
+ sql = 'select count(*) = 0 from pg_stat_user_tables;'
53
+ result = exec_sql_on_uri(sql, @target)
54
+ unless result == " ?column? \n----------\n t\n(1 row)\n\n"
55
+ command.error("Remote database is not empty.\nPlease create a new database, or use `turbot pg:reset`")
56
+ end
57
+ end
58
+
59
+ def gen_pg_dump_command(uri)
60
+ # It is occasionally necessary to override PGSSLMODE, as when the server
61
+ # wasn't built to support SSL.
62
+ %{ env PGPASSWORD=#{uri.password} PGSSLMODE=prefer pg_dump --verbose -F c -Z 0 #{connstring(uri, :skip_d_flag)} }
63
+ end
64
+
65
+ def gen_pg_restore_command(uri)
66
+ %{ env PGPASSWORD=#{uri.password} pg_restore --verbose --no-acl --no-owner #{connstring(uri)} }
67
+ end
68
+
69
+ def connstring(uri, skip_d_flag=false)
70
+ database = uri.path[1..-1]
71
+ user = uri.user ? "-U #{uri.user}" : ""
72
+ %Q{#{user} -h #{uri.host} -p #{uri.port} #{skip_d_flag ? '' : '-d'} #{database} }
73
+ end
74
+
75
+ def fill_in_shorthand_uris!
76
+ [@target, @source].each do |uri|
77
+ uri.host ||= 'localhost'
78
+ uri.port ||= Integer(ENV['PGPORT'] || 5432)
79
+ end
80
+ end
81
+
82
+ def verify_extensions_match
83
+ # It's pretty common for local DBs to not have extensions available that
84
+ # are used by the remote bot, so take the final precaution of warning if
85
+ # the extensions available in the local database don't match. We don't
86
+ # report it if the difference is solely in the version of an extension
87
+ # used, though.
88
+ ext_sql = "SELECT extname FROM pg_extension ORDER BY extname;"
89
+ target_exts = exec_sql_on_uri(ext_sql, @target)
90
+ source_exts = exec_sql_on_uri(ext_sql, @source)
91
+ if target_exts != source_exts
92
+ command.error <<-EOM
93
+ WARNING: Extensions in newly created target database differ from existing source database.
94
+
95
+ Target extensions:
96
+ #{target_exts}
97
+ Source extensions:
98
+ #{source_exts}
99
+ HINT: You should review output to ensure that any errors
100
+ ignored are acceptable - entire tables may have been missed, where a dependency
101
+ could not be resolved. You may need to to install a postgresql-contrib package
102
+ and retry.
103
+ EOM
104
+ end
105
+ end
106
+
107
+ def exec_sql_on_uri(sql, uri)
108
+ command.send(:exec_sql_on_uri, sql, uri)
109
+ end
110
+
111
+ def run
112
+ system dump_restore_cmd
113
+ end
114
+ end
115
+