cf-uaac 1.3.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.
- data/.gitignore +8 -0
- data/Gemfile +16 -0
- data/README.md +48 -0
- data/Rakefile +50 -0
- data/bin/completion-helper +80 -0
- data/bin/uaac +5 -0
- data/bin/uaac-completion.sh +34 -0
- data/bin/uaas +7 -0
- data/cf-uaac.gemspec +48 -0
- data/lib/cli.rb +15 -0
- data/lib/cli/base.rb +277 -0
- data/lib/cli/client_reg.rb +103 -0
- data/lib/cli/common.rb +187 -0
- data/lib/cli/config.rb +163 -0
- data/lib/cli/favicon.ico +0 -0
- data/lib/cli/group.rb +85 -0
- data/lib/cli/info.rb +54 -0
- data/lib/cli/runner.rb +52 -0
- data/lib/cli/token.rb +217 -0
- data/lib/cli/user.rb +108 -0
- data/lib/cli/version.rb +18 -0
- data/lib/stub/scim.rb +387 -0
- data/lib/stub/server.rb +310 -0
- data/lib/stub/uaa.rb +485 -0
- data/spec/client_reg_spec.rb +104 -0
- data/spec/common_spec.rb +89 -0
- data/spec/group_spec.rb +93 -0
- data/spec/http_spec.rb +165 -0
- data/spec/info_spec.rb +74 -0
- data/spec/spec_helper.rb +87 -0
- data/spec/token_spec.rb +119 -0
- data/spec/user_spec.rb +61 -0
- metadata +292 -0
@@ -0,0 +1,103 @@
|
|
1
|
+
#--
|
2
|
+
# Cloud Foundry 2012.02.03 Beta
|
3
|
+
# Copyright (c) [2009-2012] VMware, Inc. All Rights Reserved.
|
4
|
+
#
|
5
|
+
# This product is licensed to you under the Apache License, Version 2.0 (the "License").
|
6
|
+
# You may not use this product except in compliance with the License.
|
7
|
+
#
|
8
|
+
# This product includes a number of subcomponents with
|
9
|
+
# separate copyright notices and license terms. Your use of these
|
10
|
+
# subcomponents is subject to the terms and conditions of the
|
11
|
+
# subcomponent's license, as noted in the LICENSE file.
|
12
|
+
#++
|
13
|
+
|
14
|
+
require 'cli/common'
|
15
|
+
|
16
|
+
module CF::UAA
|
17
|
+
|
18
|
+
class ClientCli < CommonCli
|
19
|
+
|
20
|
+
topic "Client Application Registrations", "reg"
|
21
|
+
|
22
|
+
CLIENT_SCHEMA = { scope: "list", authorized_grant_types: "list",
|
23
|
+
authorities: "list", access_token_validity: "seconds",
|
24
|
+
refresh_token_validity: "seconds", redirect_uri: "list" }
|
25
|
+
CLIENT_SCHEMA.each { |k, v| define_option(k, "--#{k} <#{v}>") }
|
26
|
+
|
27
|
+
def client_info(defaults)
|
28
|
+
info = {client_id: defaults["client_id"] || opts[:client_id]}
|
29
|
+
info[:client_secret] = opts[:secret] if opts[:secret]
|
30
|
+
del_attrs = Util.arglist(opts[:del_attrs], [])
|
31
|
+
CLIENT_SCHEMA.each_with_object(info) do |(k, p), info|
|
32
|
+
next if del_attrs.include?(k)
|
33
|
+
default = Util.strlist(defaults[k.to_s])
|
34
|
+
if opts.key?(k)
|
35
|
+
info[k] = opts[k].nil? || opts[k].empty? ? default : opts[k]
|
36
|
+
else
|
37
|
+
info[k] = opts[:interact] ?
|
38
|
+
info[k] = askd("#{k.to_s.gsub('_', ' ')} (#{p})", default): default
|
39
|
+
end
|
40
|
+
info[k] = Util.arglist(info[k]) if p == "list"
|
41
|
+
info.delete(k) unless info[k]
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
desc "clients", "List client registrations" do
|
46
|
+
pp scim_request { |cr| cr.all_pages(:client) }
|
47
|
+
end
|
48
|
+
|
49
|
+
desc "client get [name]", "Get specific client registration" do |name|
|
50
|
+
pp scim_request { |cr| cr.get(:client, cr.id(:client, clientname(name))) }
|
51
|
+
end
|
52
|
+
|
53
|
+
define_option :clone, "--clone <other_client>", "get default client settings from existing client"
|
54
|
+
define_option :interact, "--[no-]interactive", "-i", "interactively verify all values"
|
55
|
+
|
56
|
+
desc "client add [name]", "Add client registration",
|
57
|
+
*CLIENT_SCHEMA.keys, :clone, :secret, :interact do |name|
|
58
|
+
pp scim_request { |cr|
|
59
|
+
opts[:client_id] = clientname(name)
|
60
|
+
opts[:secret] = verified_pwd("New client secret", opts[:secret])
|
61
|
+
defaults = opts[:clone] ? cr.get(opts[:clone]) : {}
|
62
|
+
defaults.delete("client_id")
|
63
|
+
cr.add(:client, client_info(defaults))
|
64
|
+
}
|
65
|
+
end
|
66
|
+
|
67
|
+
desc "client update [name]", "Update client registration", *CLIENT_SCHEMA.keys,
|
68
|
+
:del_attrs, :interact do |name|
|
69
|
+
pp scim_request { |cr|
|
70
|
+
opts[:client_id] = clientname(name)
|
71
|
+
info = client_info(cr.get(:client, opts[:client_id]))
|
72
|
+
info.length > 1 ? cr.put(:client, info) : gripe("Nothing to update. Use -i for interactive update.")
|
73
|
+
}
|
74
|
+
end
|
75
|
+
|
76
|
+
desc "client delete [name]", "Delete client registration" do |name|
|
77
|
+
pp scim_request { |cr|
|
78
|
+
cr.delete(:client, clientname(name))
|
79
|
+
"client registration deleted"
|
80
|
+
}
|
81
|
+
end
|
82
|
+
|
83
|
+
desc "secret set [name]", "Set client secret", :secret do |name|
|
84
|
+
pp scim_request { |cr|
|
85
|
+
cr.change_secret(clientname(name), verified_pwd("New secret", opts[:secret]))
|
86
|
+
"client secret successfully set"
|
87
|
+
}
|
88
|
+
end
|
89
|
+
|
90
|
+
define_option :old_secret, "-o", "--old_secret <secret>", "current secret"
|
91
|
+
desc "secret change", "Change secret for authenticated client in current context", :old_secret, :secret do
|
92
|
+
return gripe "context not set" unless client_id = Config.context.to_s
|
93
|
+
scim_request { |cr|
|
94
|
+
old = opts[:old_secret] || ask_pwd("Current secret")
|
95
|
+
cr.change_secret(client_id, verified_pwd("New secret", opts[:secret]), old)
|
96
|
+
"client secret successfully changed"
|
97
|
+
}
|
98
|
+
end
|
99
|
+
|
100
|
+
end
|
101
|
+
|
102
|
+
end
|
103
|
+
|
data/lib/cli/common.rb
ADDED
@@ -0,0 +1,187 @@
|
|
1
|
+
#--
|
2
|
+
# Cloud Foundry 2012.02.03 Beta
|
3
|
+
# Copyright (c) [2009-2012] VMware, Inc. All Rights Reserved.
|
4
|
+
#
|
5
|
+
# This product is licensed to you under the Apache License, Version 2.0 (the "License").
|
6
|
+
# You may not use this product except in compliance with the License.
|
7
|
+
#
|
8
|
+
# This product includes a number of subcomponents with
|
9
|
+
# separate copyright notices and license terms. Your use of these
|
10
|
+
# subcomponents is subject to the terms and conditions of the
|
11
|
+
# subcomponent's license, as noted in the LICENSE file.
|
12
|
+
#++
|
13
|
+
|
14
|
+
require 'cli/base'
|
15
|
+
require 'cli/config'
|
16
|
+
require 'uaa'
|
17
|
+
|
18
|
+
module CF::UAA
|
19
|
+
|
20
|
+
class CommonCli < Topic
|
21
|
+
|
22
|
+
def trace?; opts[:trace] end
|
23
|
+
def debug?; opts[:debug] end
|
24
|
+
|
25
|
+
def auth_header
|
26
|
+
unless (ttype = Config.value(:token_type)) && (token = Config.value(:access_token))
|
27
|
+
raise "Need an access token to complete this command. Please login."
|
28
|
+
end
|
29
|
+
"#{ttype} #{token}"
|
30
|
+
end
|
31
|
+
|
32
|
+
def username(name); name || ask("User name") end
|
33
|
+
def userpwd(pwd = opts[:password]); pwd || ask_pwd("Password") end
|
34
|
+
def clientname(name = opts[:client]); name || ask("Client name") end
|
35
|
+
def clientsecret(name = opts[:secret]); name || ask_pwd("Client secret") end
|
36
|
+
|
37
|
+
def verified_pwd(prompt, pwd = nil)
|
38
|
+
while pwd.nil?
|
39
|
+
pwd_a = ask_pwd prompt
|
40
|
+
pwd_b = ask_pwd "Verify #{prompt.downcase}"
|
41
|
+
pwd = pwd_a if pwd_a == pwd_b
|
42
|
+
end
|
43
|
+
pwd
|
44
|
+
end
|
45
|
+
|
46
|
+
def askd(prompt, defary)
|
47
|
+
return ask(prompt) unless defary
|
48
|
+
result = ask("#{prompt} [#{Util.strlist(defary)}]")
|
49
|
+
result.nil? || result.empty? ? defary : result
|
50
|
+
end
|
51
|
+
|
52
|
+
def complain(e)
|
53
|
+
case e
|
54
|
+
when TargetError then gripe "\n#{e.message}:\n#{JSON.pretty_generate(e.info)}"
|
55
|
+
when Exception
|
56
|
+
gripe "\n#{e.class}: #{e.message}\n\n"
|
57
|
+
gripe e.backtrace if trace?
|
58
|
+
when String then gripe e
|
59
|
+
else gripe "unknown type of gripe: #{e.class}, #{e}"
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def handle_request
|
64
|
+
yield
|
65
|
+
rescue Exception => e
|
66
|
+
complain e
|
67
|
+
end
|
68
|
+
|
69
|
+
def scim_request
|
70
|
+
yield Scim.new(Config.target, auth_header)
|
71
|
+
rescue Exception => e
|
72
|
+
complain e
|
73
|
+
end
|
74
|
+
|
75
|
+
def update_target_info(info = nil)
|
76
|
+
return if !info && Config.target_value(:prompts)
|
77
|
+
info ||= Misc.server(Config.target)
|
78
|
+
Config.target_opts(prompts: info['prompts'])
|
79
|
+
Config.target_opts(token_endpoint: info['token_endpoint']) if info['token_endpoint']
|
80
|
+
info
|
81
|
+
end
|
82
|
+
|
83
|
+
end
|
84
|
+
|
85
|
+
class MiscCli < CommonCli
|
86
|
+
|
87
|
+
topic "Miscellaneous", "misc"
|
88
|
+
|
89
|
+
desc "version", "Display version" do
|
90
|
+
say "UAA client #{CLI_VERSION}"
|
91
|
+
end
|
92
|
+
|
93
|
+
define_option :trace, "--[no-]trace", "-t", "display extra verbose debug information"
|
94
|
+
define_option :debug, "--[no-]debug", "-d", "display debug information"
|
95
|
+
define_option :help, "--[no-]help", "-h", "display helpful information"
|
96
|
+
define_option :version, "--[no-]version", "-v", "show version"
|
97
|
+
define_option :config, "--config [string|file]", "file to get/save configuration information or yaml string"
|
98
|
+
|
99
|
+
desc "help [topic|command...]", "Display summary or details of command or topic" do |*args|
|
100
|
+
# handle hidden command, output commands in form for bash completion
|
101
|
+
return say_commands if args.length == 1 && args[0] == "commands"
|
102
|
+
args.empty? ? say_help : say_command_help(args)
|
103
|
+
end
|
104
|
+
|
105
|
+
def normalize_url(url, scheme = nil)
|
106
|
+
url = url.strip.gsub(/\/*$/, "")
|
107
|
+
raise ArgumentError, "invalid whitespace in target url" if url =~ /\s/
|
108
|
+
unless url =~ /^https?:\/\//
|
109
|
+
return unless scheme
|
110
|
+
url = "#{scheme}://#{url}"
|
111
|
+
end
|
112
|
+
url = URI.parse(url)
|
113
|
+
url.host.downcase!
|
114
|
+
url.to_s.to_sym
|
115
|
+
end
|
116
|
+
|
117
|
+
def bad_uaa_url(url, info)
|
118
|
+
info.replace(Misc.server(url.to_s))
|
119
|
+
nil
|
120
|
+
rescue Exception => e
|
121
|
+
"failed to access #{url}: #{e.message}"
|
122
|
+
end
|
123
|
+
|
124
|
+
define_option :force, "--[no-]force", "-f", "set even if target does not respond"
|
125
|
+
desc "target [uaa_url]", "Display current or set new target", :force do |uaa_url|
|
126
|
+
msg, info = nil, {}
|
127
|
+
if uaa_url
|
128
|
+
if uaa_url.to_i.to_s == uaa_url
|
129
|
+
return gripe "invalid target index" unless url = Config.target?(uaa_url.to_i)
|
130
|
+
elsif url = normalize_url(uaa_url)
|
131
|
+
return gripe msg if (msg = bad_uaa_url(url, info)) unless opts[:force] || Config.target?(url)
|
132
|
+
elsif !Config.target?(url = normalize_url(uaa_url, "https")) &&
|
133
|
+
!Config.target?(url = normalize_url(uaa_url, "http"))
|
134
|
+
if opts[:force]
|
135
|
+
url = normalize_url(uaa_url, "https")
|
136
|
+
elsif bad_uaa_url((url = normalize_url(uaa_url, "https")), info)
|
137
|
+
return gripe msg if msg = bad_uaa_url((url = normalize_url(uaa_url, "http")), info)
|
138
|
+
end
|
139
|
+
end
|
140
|
+
Config.target = url # we now have a canonical url set to https if possible
|
141
|
+
update_target_info(info) if info[:prompts]
|
142
|
+
end
|
143
|
+
return say "no target set" unless Config.target
|
144
|
+
return say "target set to #{Config.target}" unless Config.context
|
145
|
+
say "target set to #{Config.target}, with context #{Config.context}"
|
146
|
+
end
|
147
|
+
|
148
|
+
desc "targets", "Display all targets" do
|
149
|
+
cfg = Config.config
|
150
|
+
return say "\nno targets\n" if cfg.empty?
|
151
|
+
cfg.each_with_index { |(k, v), i| pp "#{i} #{v[:current] ? '*' : ' '} #{k}" }
|
152
|
+
say "\n"
|
153
|
+
end
|
154
|
+
|
155
|
+
def config_pp(tgt = nil, ctx = nil)
|
156
|
+
Config.config.each_with_index do |(k, v), i|
|
157
|
+
next if tgt && tgt != k
|
158
|
+
say ""
|
159
|
+
splat = v[:current] ? '*' : ' '
|
160
|
+
pp "[#{i}]#{splat}[#{k}]"
|
161
|
+
v.each {|tk, tv| pp(tv, 2, terminal_columns, tk) unless [:contexts, :current, :prompts].include?(tk)}
|
162
|
+
next unless v[:contexts]
|
163
|
+
v[:contexts].each_with_index do |(sk, sv), si|
|
164
|
+
next if ctx && ctx != sk
|
165
|
+
say ""
|
166
|
+
splat = sv[:current] && v[:current]? '*' : ' '
|
167
|
+
sv.delete(:current)
|
168
|
+
pp "[#{si}]#{splat}[#{sk}]", 2
|
169
|
+
pp sv, 4
|
170
|
+
end
|
171
|
+
end
|
172
|
+
say ""
|
173
|
+
end
|
174
|
+
|
175
|
+
desc "context [name]", "Display or set current context" do |ctx|
|
176
|
+
ctx = ctx.to_i if ctx.to_i.to_s == ctx
|
177
|
+
Config.context = ctx if ctx && Config.valid_context(ctx)
|
178
|
+
(opts[:trace] ? Config.add_opts(trace: true) : Config.delete_attr(:trace)) if opts.key?(:trace)
|
179
|
+
return say "no context set in target #{Config.target}" unless Config.context
|
180
|
+
config_pp Config.target, Config.context
|
181
|
+
end
|
182
|
+
|
183
|
+
desc "contexts", "Display all contexts" do config_pp end
|
184
|
+
|
185
|
+
end
|
186
|
+
|
187
|
+
end
|
data/lib/cli/config.rb
ADDED
@@ -0,0 +1,163 @@
|
|
1
|
+
#--
|
2
|
+
# Cloud Foundry 2012.02.03 Beta
|
3
|
+
# Copyright (c) [2009-2012] VMware, Inc. All Rights Reserved.
|
4
|
+
#
|
5
|
+
# This product is licensed to you under the Apache License, Version 2.0 (the "License").
|
6
|
+
# You may not use this product except in compliance with the License.
|
7
|
+
#
|
8
|
+
# This product includes a number of subcomponents with
|
9
|
+
# separate copyright notices and license terms. Your use of these
|
10
|
+
# subcomponents is subject to the terms and conditions of the
|
11
|
+
# subcomponent's license, as noted in the LICENSE file.
|
12
|
+
#++
|
13
|
+
|
14
|
+
require 'yaml'
|
15
|
+
require 'uaa/util'
|
16
|
+
|
17
|
+
module CF::UAA
|
18
|
+
|
19
|
+
class Config
|
20
|
+
|
21
|
+
class << self; attr_reader :target, :context end
|
22
|
+
|
23
|
+
def self.config; @config ? @config.dup : {} end
|
24
|
+
def self.loaded?; !!@config end
|
25
|
+
def self.yaml; YAML.dump(Util.hash_keys(@config, :tostr)) end
|
26
|
+
def self.target?(tgt) tgt if @config[tgt = subhash_key(@config, tgt)] end
|
27
|
+
|
28
|
+
# if a yaml string is provided, config is loaded from the string, otherwise
|
29
|
+
# config is assumed to be a file name to read and store config.
|
30
|
+
# config can be retrieved in yaml form from Config.yaml
|
31
|
+
def self.load(config = nil)
|
32
|
+
@config = {}
|
33
|
+
return unless config
|
34
|
+
if config =~ /^---/ || config == ""
|
35
|
+
@config = config == "" ? {} : YAML.load(config)
|
36
|
+
@config_file = nil
|
37
|
+
elsif File.exists?(@config_file = config)
|
38
|
+
if (@config = YAML.load_file(@config_file)) && @config.is_a?(Hash)
|
39
|
+
@config.each { |k, v| break @config = nil if k.to_s =~ / / }
|
40
|
+
end
|
41
|
+
unless @config && @config.is_a?(Hash)
|
42
|
+
STDERR.puts "", "Invalid config file #{@config_file}.",
|
43
|
+
"If it's from an old version of uaac, please remove it.",
|
44
|
+
"Note that the uaac command structure has changed.",
|
45
|
+
"Please review the new commands with 'uaac help'", ""
|
46
|
+
exit 1
|
47
|
+
end
|
48
|
+
else # file doesn't exist, make sure we can write it now
|
49
|
+
File.open(@config_file, 'w') { |f| f.write("--- {}\n\n") }
|
50
|
+
end
|
51
|
+
Util.hash_keys!(@config, :tosym)
|
52
|
+
@context = current_subhash(@config[@target][:contexts]) if @target = current_subhash(@config)
|
53
|
+
end
|
54
|
+
|
55
|
+
def self.save
|
56
|
+
File.open(@config_file, 'w') { |f| YAML.dump(Util.hash_keys(@config, :tostr), f) } if @config_file
|
57
|
+
true
|
58
|
+
end
|
59
|
+
|
60
|
+
def self.target=(tgt)
|
61
|
+
unless t = set_current_subhash(@config, tgt, @target)
|
62
|
+
raise ArgumentError, "invalid target, #{tgt}"
|
63
|
+
end
|
64
|
+
@context = current_subhash(@config[t][:contexts])
|
65
|
+
save
|
66
|
+
@target = t
|
67
|
+
end
|
68
|
+
|
69
|
+
def self.target_opts(hash)
|
70
|
+
raise ArgumentError, "target not set" unless @target
|
71
|
+
return unless hash and !hash.empty?
|
72
|
+
raise ArgumentError, "'contexts' is a reserved key" if hash.key?(:contexts)
|
73
|
+
@config[@target].merge! Util.hash_keys(hash, :tosym)
|
74
|
+
save
|
75
|
+
end
|
76
|
+
|
77
|
+
def self.target_value(attr)
|
78
|
+
raise ArgumentError, "target not set" unless @target
|
79
|
+
@config[@target][attr]
|
80
|
+
end
|
81
|
+
|
82
|
+
def self.context=(ctx)
|
83
|
+
raise ArgumentError, "target not set" unless @target
|
84
|
+
unless c = set_current_subhash(@config[@target][:contexts] ||= {}, ctx, @context)
|
85
|
+
raise ArgumentError, "invalid context, #{ctx}"
|
86
|
+
end
|
87
|
+
save
|
88
|
+
@context = c
|
89
|
+
end
|
90
|
+
|
91
|
+
def self.valid_context(ctx)
|
92
|
+
raise ArgumentError, "target not set" unless @target
|
93
|
+
k = existing_key(@config[@target][:contexts] ||= {}, ctx)
|
94
|
+
raise ArgumentError, "unknown context #{ctx}" unless k
|
95
|
+
k
|
96
|
+
end
|
97
|
+
|
98
|
+
def self.delete(tgt = nil, ctx = nil)
|
99
|
+
if tgt && ctx
|
100
|
+
@config[tgt][:contexts].delete(ctx = valid_context(ctx))
|
101
|
+
@context = nil if tgt == @target && ctx == @context
|
102
|
+
elsif tgt
|
103
|
+
@config.delete(tgt)
|
104
|
+
@target = @context = nil if tgt == @target
|
105
|
+
else
|
106
|
+
@target, @context, @config = nil, nil, {}
|
107
|
+
end
|
108
|
+
save
|
109
|
+
end
|
110
|
+
|
111
|
+
def self.add_opts(hash)
|
112
|
+
raise ArgumentError, "target and context not set" unless @target && @context
|
113
|
+
return unless hash and !hash.empty?
|
114
|
+
@config[@target][:contexts][@context].merge! Util.hash_keys(hash, :tosym)
|
115
|
+
save
|
116
|
+
end
|
117
|
+
|
118
|
+
def self.value(attr)
|
119
|
+
raise ArgumentError, "target and context not set" unless @target && @context
|
120
|
+
@config[@target][:contexts][@context][attr]
|
121
|
+
end
|
122
|
+
|
123
|
+
def self.delete_attr(attr)
|
124
|
+
raise ArgumentError, "target and context not set" unless @target && @context
|
125
|
+
@config[@target][:contexts][@context].delete(attr)
|
126
|
+
end
|
127
|
+
|
128
|
+
# these are all class methods and so can't really be private, but the
|
129
|
+
# methods below here are not intended to be part of the public interface
|
130
|
+
private
|
131
|
+
|
132
|
+
def self.current_subhash(hash)
|
133
|
+
return unless hash
|
134
|
+
key = nil
|
135
|
+
hash.each { |k, v| key ? v.delete(:current) : (key = k if v[:current]) }
|
136
|
+
key
|
137
|
+
end
|
138
|
+
|
139
|
+
# key can be an integer index of the desired subhash or the key symbol or string
|
140
|
+
def self.subhash_key(hash, key)
|
141
|
+
case key
|
142
|
+
when Integer then hash.each_with_index { |(k, v), i| return k if i == key }; nil
|
143
|
+
when String then key.downcase.to_sym
|
144
|
+
when Symbol then key.to_s.downcase.to_sym
|
145
|
+
else nil
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
def self.existing_key(hash, key)
|
150
|
+
k = subhash_key(hash, key)
|
151
|
+
k if hash[k]
|
152
|
+
end
|
153
|
+
|
154
|
+
def self.set_current_subhash(hash, newcurrent, oldcurrent)
|
155
|
+
return unless k = subhash_key(hash, newcurrent)
|
156
|
+
hash[oldcurrent].delete(:current) if oldcurrent
|
157
|
+
(hash[k] ||= {}).merge!(current: true)
|
158
|
+
k
|
159
|
+
end
|
160
|
+
|
161
|
+
end
|
162
|
+
|
163
|
+
end
|