google-cloud-gemserver 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.yardopts +7 -0
- data/CONTRIBUTING.md +49 -0
- data/LICENSE +202 -0
- data/README.md +179 -0
- data/bin/console +7 -0
- data/bin/google-cloud-gemserver +5 -0
- data/bin/setup +6 -0
- data/lib/google/cloud/gemserver.rb +33 -0
- data/lib/google/cloud/gemserver/authentication.rb +254 -0
- data/lib/google/cloud/gemserver/backend.rb +33 -0
- data/lib/google/cloud/gemserver/backend/gemstash_server.rb +60 -0
- data/lib/google/cloud/gemserver/backend/key.rb +152 -0
- data/lib/google/cloud/gemserver/backend/stats.rb +149 -0
- data/lib/google/cloud/gemserver/backend/storage_sync.rb +186 -0
- data/lib/google/cloud/gemserver/cli.rb +174 -0
- data/lib/google/cloud/gemserver/cli/cloud_sql.rb +196 -0
- data/lib/google/cloud/gemserver/cli/project.rb +143 -0
- data/lib/google/cloud/gemserver/cli/request.rb +130 -0
- data/lib/google/cloud/gemserver/cli/server.rb +337 -0
- data/lib/google/cloud/gemserver/configuration.rb +505 -0
- data/lib/google/cloud/gemserver/gcs.rb +171 -0
- data/lib/google/cloud/gemserver/version.rb +23 -0
- data/lib/patched/configuration.rb +29 -0
- data/lib/patched/dependencies.rb +33 -0
- data/lib/patched/env.rb +42 -0
- data/lib/patched/gem_pusher.rb +28 -0
- data/lib/patched/gem_yanker.rb +28 -0
- data/lib/patched/storage.rb +37 -0
- data/lib/patched/web.rb +64 -0
- metadata +271 -0
data/bin/setup
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
# Copyright 2017 Google Inc.
|
2
|
+
#
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
# you may not use this file except in compliance with the License.
|
5
|
+
# You may obtain a copy of the License at
|
6
|
+
#
|
7
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
8
|
+
#
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
# See the License for the specific language governing permissions and
|
13
|
+
# limitations under the License.
|
14
|
+
|
15
|
+
module Google
|
16
|
+
module Cloud
|
17
|
+
##
|
18
|
+
#
|
19
|
+
# # Gemserver
|
20
|
+
#
|
21
|
+
# Gemserver provides a command line interface to create, manage, and deploy
|
22
|
+
# a gemserver to a Google Cloud Platform project.
|
23
|
+
#
|
24
|
+
module Gemserver
|
25
|
+
autoload :CLI, "google/cloud/gemserver/cli"
|
26
|
+
autoload :VERSION, "google/cloud/gemserver/version"
|
27
|
+
autoload :Configuration, "google/cloud/gemserver/configuration"
|
28
|
+
autoload :GCS, "google/cloud/gemserver/gcs"
|
29
|
+
autoload :Authentication, "google/cloud/gemserver/authentication"
|
30
|
+
autoload :Backend, "google/cloud/gemserver/backend"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,254 @@
|
|
1
|
+
# Copyright 2017 Google Inc.
|
2
|
+
#
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
# you may not use this file except in compliance with the License.
|
5
|
+
# You may obtain a copy of the License at
|
6
|
+
#
|
7
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
8
|
+
#
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
# See the License for the specific language governing permissions and
|
13
|
+
# limitations under the License.
|
14
|
+
|
15
|
+
require "google/cloud/gemserver"
|
16
|
+
require "json"
|
17
|
+
require "googleauth"
|
18
|
+
require "net/http"
|
19
|
+
require "uri"
|
20
|
+
|
21
|
+
module Google
|
22
|
+
module Cloud
|
23
|
+
module Gemserver
|
24
|
+
##
|
25
|
+
#
|
26
|
+
# # Authentication
|
27
|
+
#
|
28
|
+
# Manages the permissions of the currently logged in user with the gcloud
|
29
|
+
# sdk.
|
30
|
+
#
|
31
|
+
class Authentication
|
32
|
+
|
33
|
+
##
|
34
|
+
# The project id of the Google App Engine project the gemserver was
|
35
|
+
# deployed to.
|
36
|
+
# @return [String]
|
37
|
+
attr_accessor :proj
|
38
|
+
|
39
|
+
##
|
40
|
+
# Creates the Authentication object and sets the project id field.
|
41
|
+
def initialize
|
42
|
+
@proj = Configuration.new[:proj_id]
|
43
|
+
end
|
44
|
+
|
45
|
+
##
|
46
|
+
# Checks if the currently logged in user can modify the gemserver
|
47
|
+
# i.e. create keys.
|
48
|
+
#
|
49
|
+
# @return [Boolean]
|
50
|
+
def can_modify?
|
51
|
+
user = curr_user
|
52
|
+
owners.each do |owner|
|
53
|
+
return true if extract_account(owner) == user
|
54
|
+
end
|
55
|
+
editors.each do |editor|
|
56
|
+
return true if extract_account(editor) == user
|
57
|
+
end
|
58
|
+
puts "You are either not authenticated with gcloud or lack access" \
|
59
|
+
" to the gemserver."
|
60
|
+
false
|
61
|
+
end
|
62
|
+
|
63
|
+
##
|
64
|
+
# Generates an access token from a user authenticated by gcloud.
|
65
|
+
#
|
66
|
+
# @return [String]
|
67
|
+
def access_token
|
68
|
+
return unless can_modify?
|
69
|
+
scope = ["https://www.googleapis.com/auth/cloud-platform"]
|
70
|
+
auth = Google::Auth.get_application_default scope
|
71
|
+
auth.fetch_access_token!
|
72
|
+
end
|
73
|
+
|
74
|
+
##
|
75
|
+
# @private Implicitly checks if the account that generated the token
|
76
|
+
# has edit permissions on the Google Cloud Platform project by issuing
|
77
|
+
# a redundant update to the project (update to original settings).
|
78
|
+
#
|
79
|
+
# @param [String] The authentication token generated from gcloud.
|
80
|
+
#
|
81
|
+
# @return [Boolean]
|
82
|
+
def validate_token auth_header
|
83
|
+
token = auth_header.split.drop(1)[0]
|
84
|
+
|
85
|
+
appengine_url = "https://appengine.googleapis.com"
|
86
|
+
endpoint = "/v1/apps/#{@proj}/services/default?updateMask=split"
|
87
|
+
version = appengine_version token
|
88
|
+
split = {
|
89
|
+
"split" => {
|
90
|
+
"allocations" => {
|
91
|
+
version.to_s => 1
|
92
|
+
}
|
93
|
+
}
|
94
|
+
}
|
95
|
+
res = send_req appengine_url, endpoint, Net::HTTP::Patch, token, split
|
96
|
+
if check_status(res)
|
97
|
+
op = JSON.parse(res.body)["name"]
|
98
|
+
wait_for_op appengine_url, "/v1/#{op}", token
|
99
|
+
true
|
100
|
+
else
|
101
|
+
false
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
private
|
106
|
+
|
107
|
+
##
|
108
|
+
# @private Fetches the latest version of the deployed Google App Engine
|
109
|
+
# instance running the gemserver (default service only).
|
110
|
+
#
|
111
|
+
# @param [String] The authentication token generated from gcloud.
|
112
|
+
#
|
113
|
+
# @return [String]
|
114
|
+
def appengine_version token
|
115
|
+
appengine_url = "https://appengine.googleapis.com"
|
116
|
+
path = "/v1/apps/#{@proj}/services/default"
|
117
|
+
res = send_req appengine_url, path, Net::HTTP::Get, token
|
118
|
+
|
119
|
+
fail "Unauthorized" unless check_status(res)
|
120
|
+
|
121
|
+
JSON.parse(res.body)["split"]["allocations"].first[0]
|
122
|
+
end
|
123
|
+
|
124
|
+
##
|
125
|
+
# @private Sends a request to a given URL with given parameters.
|
126
|
+
#
|
127
|
+
# @param [String] dom The protocol + domain name of the request.
|
128
|
+
#
|
129
|
+
# @param [String] path The path of the URL.
|
130
|
+
#
|
131
|
+
# @param [Net::HTTP] type The type of request to be made.
|
132
|
+
#
|
133
|
+
# @param [String] token The authentication token used in the header.
|
134
|
+
#
|
135
|
+
# @param [Hash] params Additional parameters send in the request body.
|
136
|
+
#
|
137
|
+
# @return [Net::HTTPResponse]
|
138
|
+
def send_req dom, path, type, token, params = nil
|
139
|
+
uri = URI.parse dom
|
140
|
+
http = Net::HTTP.new uri.host, uri.port
|
141
|
+
http.use_ssl = true if dom.include? "https"
|
142
|
+
|
143
|
+
req = type.new path
|
144
|
+
req["Authorization"] = Signet::OAuth2.generate_bearer_authorization_header token
|
145
|
+
unless type == Net::HTTP::Get
|
146
|
+
if params
|
147
|
+
req["Content-Type"] = "application/json"
|
148
|
+
req.body = params.to_json
|
149
|
+
end
|
150
|
+
end
|
151
|
+
http.request req
|
152
|
+
end
|
153
|
+
|
154
|
+
##
|
155
|
+
# @private Waits for a project update operation to complete.
|
156
|
+
#
|
157
|
+
# @param [String] dom The domain and protocol of the request.
|
158
|
+
#
|
159
|
+
# @param [String] path The path of the request containing the operation
|
160
|
+
# ID.
|
161
|
+
#
|
162
|
+
# @param [String] token The authorization token in the request.
|
163
|
+
#
|
164
|
+
# @param [Integer] timeout The length of time the operation is polled.
|
165
|
+
def wait_for_op dom, path, token, timeout = 60
|
166
|
+
start = Time.now
|
167
|
+
loop do
|
168
|
+
if Time.now - start > timeout
|
169
|
+
fail "Operation at #{path} failed to complete in time"
|
170
|
+
else
|
171
|
+
res = send_req dom, path, Net::HTTP::Get, token
|
172
|
+
if JSON.parse(res.body)["done"] == true
|
173
|
+
break
|
174
|
+
end
|
175
|
+
sleep 1
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
##
|
181
|
+
# @private Checks if a request response matches a given status code.
|
182
|
+
#
|
183
|
+
# @param [Net::HTTPResponse] reponse The response from a request.
|
184
|
+
#
|
185
|
+
# @param [Integer] code The desired response code.
|
186
|
+
#
|
187
|
+
# @return [Boolean]
|
188
|
+
def check_status response, code = 200
|
189
|
+
response.code.to_i == code
|
190
|
+
end
|
191
|
+
|
192
|
+
##
|
193
|
+
# @private Fetches the members with a specific role that have access
|
194
|
+
# to the Google App Engine project the gemserver was deployed to.
|
195
|
+
#
|
196
|
+
# @return [Array]
|
197
|
+
def members type
|
198
|
+
yml = YAML.load(run_cmd "gcloud projects get-iam-policy #{@proj}")
|
199
|
+
yml["bindings"].select do |member_set|
|
200
|
+
member_set["role"] == type
|
201
|
+
end[0]["members"]
|
202
|
+
end
|
203
|
+
|
204
|
+
##
|
205
|
+
# @private Fetches members with a role of editor that can access the
|
206
|
+
# gemserver.
|
207
|
+
#
|
208
|
+
# @return [Array]
|
209
|
+
def editors
|
210
|
+
members "roles/editor"
|
211
|
+
end
|
212
|
+
|
213
|
+
##
|
214
|
+
# @private Fetches members with a role of owner that can access the
|
215
|
+
# gemserver.
|
216
|
+
#
|
217
|
+
# @return [Array]
|
218
|
+
def owners
|
219
|
+
members "roles/owner"
|
220
|
+
end
|
221
|
+
|
222
|
+
##
|
223
|
+
# @private Fetches the active account of the currently logged in user.
|
224
|
+
#
|
225
|
+
# @return [String]
|
226
|
+
def curr_user
|
227
|
+
raw = run_cmd "gcloud auth list --format json"
|
228
|
+
JSON.load(raw).map do |i|
|
229
|
+
return i["account"] if i["status"] == "ACTIVE"
|
230
|
+
end
|
231
|
+
abort "You are not authenticated with gcloud"
|
232
|
+
end
|
233
|
+
|
234
|
+
##
|
235
|
+
# @private Parses a gcloud "member" and removes the account prefix.
|
236
|
+
#
|
237
|
+
# @param [String] acc The member the account is extracted from.
|
238
|
+
#
|
239
|
+
# @return [String]
|
240
|
+
def extract_account acc
|
241
|
+
acc[acc.index(":") + 1 .. acc.size]
|
242
|
+
end
|
243
|
+
|
244
|
+
##
|
245
|
+
# @private Runs a given command on the local machine.
|
246
|
+
#
|
247
|
+
# @param [String] args The command to be run.
|
248
|
+
def run_cmd args
|
249
|
+
`#{args}`
|
250
|
+
end
|
251
|
+
end
|
252
|
+
end
|
253
|
+
end
|
254
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# Copyright 2017 Google Inc.
|
2
|
+
#
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
# you may not use this file except in compliance with the License.
|
5
|
+
# You may obtain a copy of the License at
|
6
|
+
#
|
7
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
8
|
+
#
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
# See the License for the specific language governing permissions and
|
13
|
+
# limitations under the License.
|
14
|
+
|
15
|
+
module Google
|
16
|
+
module Cloud
|
17
|
+
module Gemserver
|
18
|
+
##
|
19
|
+
#
|
20
|
+
# # Backend
|
21
|
+
#
|
22
|
+
# Contains services that run on Google App Engine directly leveraging
|
23
|
+
# tools such as Cloud SQL proxy.
|
24
|
+
#
|
25
|
+
module Backend
|
26
|
+
autoload :GemstashServer, "google/cloud/gemserver/backend/gemstash_server"
|
27
|
+
autoload :Key, "google/cloud/gemserver/backend/key"
|
28
|
+
autoload :Stats, "google/cloud/gemserver/backend/stats"
|
29
|
+
autoload :StorageSync, "google/cloud/gemserver/backend/storage_sync"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# Copyright 2017 Google Inc.
|
2
|
+
#
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
# you may not use this file except in compliance with the License.
|
5
|
+
# You may obtain a copy of the License at
|
6
|
+
#
|
7
|
+
# @https://www.apache.org/licenses/LICENSE-2.0
|
8
|
+
#
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
# See the License for the specific language governing permissions and
|
13
|
+
# limitations under the License.
|
14
|
+
|
15
|
+
require "patched/configuration"
|
16
|
+
require "patched/dependencies"
|
17
|
+
require "patched/env"
|
18
|
+
require "patched/gem_pusher"
|
19
|
+
require "patched/gem_yanker"
|
20
|
+
require "patched/storage"
|
21
|
+
require "patched/web"
|
22
|
+
require "gemstash"
|
23
|
+
|
24
|
+
module Google
|
25
|
+
module Cloud
|
26
|
+
module Gemserver
|
27
|
+
module Backend
|
28
|
+
##
|
29
|
+
#
|
30
|
+
# # GemstashServer
|
31
|
+
#
|
32
|
+
# The class that runs gemstash specific commands and starts the gemstash
|
33
|
+
# server. Parts of gemstash are monkey-patched with lib/patched for
|
34
|
+
# compatibility with Google Cloud Platform services such as Cloud Storage
|
35
|
+
# and Cloud SQL.
|
36
|
+
module GemstashServer
|
37
|
+
|
38
|
+
##
|
39
|
+
# Runs a given command through the gemstash gem.
|
40
|
+
#
|
41
|
+
# @param [String] args The argument passed to gemstash.
|
42
|
+
def self.start args
|
43
|
+
Gemstash::CLI.start args
|
44
|
+
end
|
45
|
+
|
46
|
+
##
|
47
|
+
# Fetches the gemstash environment given a configuration file.
|
48
|
+
#
|
49
|
+
# @param [String] config_path The path to the configuration file.
|
50
|
+
#
|
51
|
+
# @return [Gemstash::Env]
|
52
|
+
def self.env config_path
|
53
|
+
config = Gemstash::Configuration.new file: config_path
|
54
|
+
Gemstash::Env.new config
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,152 @@
|
|
1
|
+
# Copyright 2017 Google Inc.
|
2
|
+
#
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
# you may not use this file except in compliance with the License.
|
5
|
+
# You may obtain a copy of the License at
|
6
|
+
#
|
7
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
8
|
+
#
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
# See the License for the specific language governing permissions and
|
13
|
+
# limitations under the License.
|
14
|
+
|
15
|
+
require "google/cloud/gemserver"
|
16
|
+
require "stringio"
|
17
|
+
require "fileutils"
|
18
|
+
require "yaml"
|
19
|
+
|
20
|
+
module Google
|
21
|
+
module Cloud
|
22
|
+
module Gemserver
|
23
|
+
module Backend
|
24
|
+
##
|
25
|
+
# # Key
|
26
|
+
#
|
27
|
+
# Manages the creation and deletion of a key used to push gems to the
|
28
|
+
# gemserver and download them.
|
29
|
+
#
|
30
|
+
class Key
|
31
|
+
##
|
32
|
+
# A mapping from gemserver permissions to gemstash permissions.
|
33
|
+
MAPPING = {
|
34
|
+
"write" => %w[push yank],
|
35
|
+
"read" => %w[fetch]
|
36
|
+
}.freeze
|
37
|
+
|
38
|
+
##
|
39
|
+
# Aliases for read and write permissions.
|
40
|
+
ALL = ["both", "all", "", nil].freeze
|
41
|
+
|
42
|
+
##
|
43
|
+
# Path to the credentials file checked when pushing gems to the gem
|
44
|
+
# server (or any endpoint).
|
45
|
+
GEM_CREDS = File.expand_path("~/.gem")
|
46
|
+
|
47
|
+
##
|
48
|
+
# The length of a key generated by gemstash.
|
49
|
+
KEY_LENGTH = 32
|
50
|
+
|
51
|
+
##
|
52
|
+
# Creates a key with given permissions.
|
53
|
+
#
|
54
|
+
# @param permissions [String] The permissions for a key. Optional.
|
55
|
+
#
|
56
|
+
# @return [String]
|
57
|
+
def self.create_key permissions = nil
|
58
|
+
mapped = map_perms permissions
|
59
|
+
args = base_args.concat mapped
|
60
|
+
output = capture_stdout { GemstashServer.start args }
|
61
|
+
key = parse_key(output)
|
62
|
+
puts "Created key: #{key}"
|
63
|
+
key
|
64
|
+
end
|
65
|
+
|
66
|
+
##
|
67
|
+
# Deletes a given key.
|
68
|
+
#
|
69
|
+
# @param [String] key The key to delete.
|
70
|
+
def self.delete_key key
|
71
|
+
args = [
|
72
|
+
"--remove",
|
73
|
+
"--key=#{key}"
|
74
|
+
]
|
75
|
+
GemstashServer.start base_args.concat(args)
|
76
|
+
puts "Deleted key: #{key}"
|
77
|
+
true
|
78
|
+
end
|
79
|
+
|
80
|
+
##
|
81
|
+
# @private Maps read/write permissions to the permissions the
|
82
|
+
# gemstash gem uses.
|
83
|
+
#
|
84
|
+
# @param [String] perms The permissions to be mapped.
|
85
|
+
def self.map_perms perms
|
86
|
+
if perms == "write"
|
87
|
+
MAPPING["write"]
|
88
|
+
elsif ALL.include? perms
|
89
|
+
MAPPING["write"] + MAPPING["read"]
|
90
|
+
else
|
91
|
+
MAPPING["read"]
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
##
|
96
|
+
# @private Temporarily routes stdout to a temporary variable such
|
97
|
+
# that stdout from gemstash is captured.
|
98
|
+
#
|
99
|
+
# @return [String]
|
100
|
+
def self.capture_stdout
|
101
|
+
old_stdout = $stdout
|
102
|
+
$stdout = StringIO.new
|
103
|
+
yield
|
104
|
+
$stdout.string
|
105
|
+
ensure
|
106
|
+
$stdout = old_stdout
|
107
|
+
end
|
108
|
+
|
109
|
+
##
|
110
|
+
# @private The arguments passed to every gemstash key generation
|
111
|
+
# command.
|
112
|
+
#
|
113
|
+
# @return [Array]
|
114
|
+
def self.base_args
|
115
|
+
[
|
116
|
+
"authorize",
|
117
|
+
"--config-file=#{Google::Cloud::Gemserver::Configuration.new.config_path}"
|
118
|
+
]
|
119
|
+
end
|
120
|
+
|
121
|
+
##
|
122
|
+
# @private Outputs important information to the user on how they
|
123
|
+
# should set up their keys so pushing/installing gems works as
|
124
|
+
# intended.
|
125
|
+
def self.output_key_info
|
126
|
+
puts "Note: remember to add this key to ~/.gem/credentials" \
|
127
|
+
" so that you are able to push gems to the gemserver."
|
128
|
+
puts "Note: remember to add this key to your bundle config so " \
|
129
|
+
"that `bundle install` works for private gems (bundle config" \
|
130
|
+
" http://my-gemserver.appspot.com/private/ my-key"
|
131
|
+
end
|
132
|
+
|
133
|
+
##
|
134
|
+
# Parses the key from output generated from the corresponding key
|
135
|
+
# creation command in gemstash.
|
136
|
+
#
|
137
|
+
# @param [String] output The output to parse.
|
138
|
+
#
|
139
|
+
# @return [String]
|
140
|
+
def self.parse_key output
|
141
|
+
i = output.index(":")
|
142
|
+
output[i+2..i+2+KEY_LENGTH].chomp
|
143
|
+
end
|
144
|
+
|
145
|
+
private_class_method :map_perms
|
146
|
+
private_class_method :capture_stdout
|
147
|
+
private_class_method :parse_key
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|