miam 0.1.0 → 0.1.1
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.
- checksums.yaml +4 -4
- data/README.md +24 -2
- data/bin/miam +5 -4
- data/lib/miam.rb +1 -0
- data/lib/miam/client.rb +129 -6
- data/lib/miam/driver.rb +100 -0
- data/lib/miam/dsl/context.rb +22 -1
- data/lib/miam/dsl/context/group.rb +3 -3
- data/lib/miam/dsl/context/role.rb +51 -0
- data/lib/miam/dsl/context/user.rb +3 -3
- data/lib/miam/dsl/converter.rb +58 -0
- data/lib/miam/exporter.rb +95 -2
- data/lib/miam/logger.rb +4 -5
- data/lib/miam/password_manager.rb +4 -0
- data/lib/miam/version.rb +1 -1
- data/spec/miam/create_spec.rb +47 -3
- data/spec/miam/delete_spec.rb +321 -1
- data/spec/miam/rename_spec.rb +208 -1
- data/spec/miam/update_spec.rb +452 -1
- data/spec/spec_helper.rb +4 -3
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: dac584f8c0f3829974bbf66a703a0514b0bba56b
|
4
|
+
data.tar.gz: 4c1554986b97d252d2e6fea8c17dbdee4e7c0072
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a197de1545bb2bfdaa906a215d23e61eb4ff63759875929a6f01605a38c6a3c4d5bd3dfa753a6e77af7c4521e0ef5f22ac36a70c1d049c8ada9e4a211f969601
|
7
|
+
data.tar.gz: cad4724a8a76be2bfc6c92ebc5ef97bf95a5e2b08f97469ce2f1213b95cc8ac42c89d694d71cf2805b3ce7e93258a3212354e076455a1852351c81e1a1bb9c59
|
data/README.md
CHANGED
@@ -63,7 +63,7 @@ Usage: miam [options]
|
|
63
63
|
require 'other/iamfile'
|
64
64
|
|
65
65
|
user "bob", :path => "/developer/" do
|
66
|
-
login_profile password_reset_required
|
66
|
+
login_profile :password_reset_required=>true
|
67
67
|
|
68
68
|
groups(
|
69
69
|
"Admin"
|
@@ -81,7 +81,7 @@ user "bob", :path => "/developer/" do
|
|
81
81
|
end
|
82
82
|
|
83
83
|
user "mary", :path => "/staff/" do
|
84
|
-
# login_profile password_reset_required
|
84
|
+
# login_profile :password_reset_required=>true
|
85
85
|
|
86
86
|
groups(
|
87
87
|
# no group
|
@@ -113,6 +113,28 @@ group "Admin", :path => "/admin/" do
|
|
113
113
|
{"Statement"=>[{"Effect"=>"Allow", "Action"=>"*", "Resource"=>"*"}]}
|
114
114
|
end
|
115
115
|
end
|
116
|
+
|
117
|
+
role "S3", :path => "/" do
|
118
|
+
instance_profiles(
|
119
|
+
"S3"
|
120
|
+
)
|
121
|
+
|
122
|
+
assume_role_policy_document do
|
123
|
+
{"Version"=>"2012-10-17",
|
124
|
+
"Statement"=>
|
125
|
+
[{"Sid"=>"",
|
126
|
+
"Effect"=>"Allow",
|
127
|
+
"Principal"=>{"Service"=>"ec2.amazonaws.com"},
|
128
|
+
"Action"=>"sts:AssumeRole"}]}
|
129
|
+
end
|
130
|
+
|
131
|
+
policy "S3-role-policy" do
|
132
|
+
{"Version"=>"2012-10-17",
|
133
|
+
"Statement"=>[{"Effect"=>"Allow", "Action"=>"*", "Resource"=>"*"}]}
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
instance_profile "S3", :path => "/"
|
116
138
|
```
|
117
139
|
|
118
140
|
## Rename
|
data/bin/miam
CHANGED
@@ -94,8 +94,9 @@ begin
|
|
94
94
|
output_file = DEFAULT_FILENAME if output_file == '-'
|
95
95
|
requires = []
|
96
96
|
|
97
|
-
client.export do |
|
98
|
-
|
97
|
+
client.export do |type, dsl|
|
98
|
+
next if dsl.strip.empty?
|
99
|
+
iam_file = File.join(File.dirname(output_file), "#{type}.iam")
|
99
100
|
requires << iam_file
|
100
101
|
logger.info(" write `#{iam_file}`")
|
101
102
|
|
@@ -114,10 +115,10 @@ begin
|
|
114
115
|
else
|
115
116
|
if output_file == '-'
|
116
117
|
logger.info('# Export IAM')
|
117
|
-
puts client.export
|
118
|
+
puts client.export.strip
|
118
119
|
else
|
119
120
|
logger.info("Export IAM to `#{output_file}`")
|
120
|
-
open(output_file, 'wb') {|f| f.puts client.export }
|
121
|
+
open(output_file, 'wb') {|f| f.puts client.export.strip }
|
121
122
|
end
|
122
123
|
end
|
123
124
|
when :apply
|
data/lib/miam.rb
CHANGED
data/lib/miam/client.rb
CHANGED
@@ -1,4 +1,6 @@
|
|
1
1
|
class Miam::Client
|
2
|
+
include Miam::Logger::Helper
|
3
|
+
|
2
4
|
def initialize(options = {})
|
3
5
|
@options = options
|
4
6
|
aws_config = options.delete(:aws_config) || {}
|
@@ -8,15 +10,15 @@ class Miam::Client
|
|
8
10
|
end
|
9
11
|
|
10
12
|
def export
|
11
|
-
exported, group_users = Miam::Exporter.export(@iam, @options) do |export_options|
|
13
|
+
exported, group_users, instance_profile_roles = Miam::Exporter.export(@iam, @options) do |export_options|
|
12
14
|
progress(*export_options.values_at(:progress_total, :progress))
|
13
15
|
end
|
14
16
|
|
15
17
|
if block_given?
|
16
|
-
[:users, :groups].each do |
|
17
|
-
splitted = {:users => {}, :groups => {}}
|
18
|
-
splitted[
|
19
|
-
yield(
|
18
|
+
[:users, :groups, :roles, :instance_profiles].each do |type|
|
19
|
+
splitted = {:users => {}, :groups => {}, :roles => {}, :instance_profiles => {}}
|
20
|
+
splitted[type] = exported[type]
|
21
|
+
yield(type, Miam::DSL.convert(splitted, @options).strip)
|
20
22
|
end
|
21
23
|
else
|
22
24
|
Miam::DSL.convert(exported, @options)
|
@@ -32,12 +34,14 @@ class Miam::Client
|
|
32
34
|
def walk(file)
|
33
35
|
expected = load_file(file)
|
34
36
|
|
35
|
-
actual, group_users = Miam::Exporter.export(@iam, @options) do |export_options|
|
37
|
+
actual, group_users, instance_profile_roles = Miam::Exporter.export(@iam, @options) do |export_options|
|
36
38
|
progress(*export_options.values_at(:progress_total, :progress))
|
37
39
|
end
|
38
40
|
|
39
41
|
updated = walk_groups(expected[:groups], actual[:groups], actual[:users], group_users)
|
40
42
|
updated = walk_users(expected[:users], actual[:users], group_users) || updated
|
43
|
+
updated = walk_instance_profiles(expected[:instance_profiles], actual[:instance_profiles], actual[:roles], instance_profile_roles) || updated
|
44
|
+
updated = walk_roles(expected[:roles], actual[:roles], instance_profile_roles) || updated
|
41
45
|
|
42
46
|
if @options[:dry_run]
|
43
47
|
false
|
@@ -168,6 +172,125 @@ class Miam::Client
|
|
168
172
|
walk_policies(:group, group_name, expected_attrs[:policies], actual_attrs[:policies])
|
169
173
|
end
|
170
174
|
|
175
|
+
def walk_roles(expected, actual, instance_profile_roles)
|
176
|
+
updated = false
|
177
|
+
|
178
|
+
expected.each do |role_name, expected_attrs|
|
179
|
+
actual_attrs = actual.delete(role_name)
|
180
|
+
|
181
|
+
if actual_attrs
|
182
|
+
updated = walk_role(role_name, expected_attrs, actual_attrs) || updated
|
183
|
+
else
|
184
|
+
actual_attrs = @driver.create_role(role_name, expected_attrs)
|
185
|
+
walk_role(role_name, expected_attrs, actual_attrs)
|
186
|
+
updated = true
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
actual.each do |role_name, attrs|
|
191
|
+
instance_profile_names = []
|
192
|
+
|
193
|
+
instance_profile_roles.each do |instance_profile_name, roles|
|
194
|
+
if roles.include?(role_name)
|
195
|
+
instance_profile_names << instance_profile_name
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
@driver.delete_role(role_name, instance_profile_names, attrs)
|
200
|
+
|
201
|
+
instance_profile_roles.each do |instance_profile_name, roles|
|
202
|
+
roles.delete(role_name)
|
203
|
+
end
|
204
|
+
|
205
|
+
updated = true
|
206
|
+
end
|
207
|
+
|
208
|
+
updated
|
209
|
+
end
|
210
|
+
|
211
|
+
def walk_role(role_name, expected_attrs, actual_attrs)
|
212
|
+
if expected_attrs.values_at(:path) != actual_attrs.values_at(:path)
|
213
|
+
log(:warn, "Role `#{role_name}`: 'path' cannot be updated", :color => :yellow)
|
214
|
+
end
|
215
|
+
|
216
|
+
updated = walk_assume_role_policy(role_name, expected_attrs[:assume_role_policy_document], actual_attrs[:assume_role_policy_document])
|
217
|
+
updated = walk_role_instance_profiles(role_name, expected_attrs[:instance_profiles], actual_attrs[:instance_profiles]) || updated
|
218
|
+
walk_policies(:role, role_name, expected_attrs[:policies], actual_attrs[:policies]) || updated
|
219
|
+
end
|
220
|
+
|
221
|
+
def walk_assume_role_policy(role_name, expected_assume_role_policy, actual_assume_role_policy)
|
222
|
+
updated = false
|
223
|
+
|
224
|
+
if expected_assume_role_policy != actual_assume_role_policy
|
225
|
+
@driver.update_assume_role_policy(role_name, expected_assume_role_policy)
|
226
|
+
updated = true
|
227
|
+
end
|
228
|
+
|
229
|
+
updated
|
230
|
+
end
|
231
|
+
|
232
|
+
def walk_role_instance_profiles(role_name, expected_instance_profiles, actual_instance_profiles)
|
233
|
+
expected_instance_profiles = expected_instance_profiles.sort
|
234
|
+
actual_instance_profiles = actual_instance_profiles.sort
|
235
|
+
updated = false
|
236
|
+
|
237
|
+
if expected_instance_profiles != actual_instance_profiles
|
238
|
+
add_instance_profiles = expected_instance_profiles - actual_instance_profiles
|
239
|
+
remove_instance_profiles = actual_instance_profiles - expected_instance_profiles
|
240
|
+
|
241
|
+
unless add_instance_profiles.empty?
|
242
|
+
@driver.add_role_to_instance_profiles(role_name, add_instance_profiles)
|
243
|
+
end
|
244
|
+
|
245
|
+
unless remove_instance_profiles.empty?
|
246
|
+
@driver.remove_role_from_instance_profiles(role_name, remove_instance_profiles)
|
247
|
+
end
|
248
|
+
|
249
|
+
updated = true
|
250
|
+
end
|
251
|
+
|
252
|
+
updated
|
253
|
+
end
|
254
|
+
|
255
|
+
def walk_instance_profiles(expected, actual, actual_roles, instance_profile_roles)
|
256
|
+
updated = false
|
257
|
+
|
258
|
+
expected.each do |instance_profile_name, expected_attrs|
|
259
|
+
actual_attrs = actual.delete(instance_profile_name)
|
260
|
+
|
261
|
+
if actual_attrs
|
262
|
+
updated = walk_instance_profile(instance_profile_name, expected_attrs, actual_attrs) || updated
|
263
|
+
else
|
264
|
+
actual_attrs = @driver.create_instance_profile(instance_profile_name, expected_attrs)
|
265
|
+
walk_instance_profile(instance_profile_name, expected_attrs, actual_attrs)
|
266
|
+
updated = true
|
267
|
+
end
|
268
|
+
end
|
269
|
+
|
270
|
+
actual.each do |instance_profile_name, attrs|
|
271
|
+
roles_in_instance_profile = instance_profile_roles.delete(instance_profile_name) || []
|
272
|
+
@driver.delete_instance_profile(instance_profile_name, attrs, roles_in_instance_profile)
|
273
|
+
|
274
|
+
actual_roles.each do |role_name, role_attrs|
|
275
|
+
role_attrs[:instance_profiles].delete(instance_profile_name)
|
276
|
+
end
|
277
|
+
|
278
|
+
updated = true
|
279
|
+
end
|
280
|
+
|
281
|
+
updated
|
282
|
+
end
|
283
|
+
|
284
|
+
def walk_instance_profile(instance_profile_name, expected_attrs, actual_attrs)
|
285
|
+
updated = false
|
286
|
+
|
287
|
+
if expected_attrs != actual_attrs
|
288
|
+
log(:warn, "InstanceProfile `#{instance_profile_name}`: 'path' cannot be updated", :color => :yellow)
|
289
|
+
end
|
290
|
+
|
291
|
+
updated
|
292
|
+
end
|
293
|
+
|
171
294
|
def scan_rename(type, expected, actual, group_users)
|
172
295
|
updated = false
|
173
296
|
|
data/lib/miam/driver.rb
CHANGED
@@ -151,6 +151,106 @@ class Miam::Driver
|
|
151
151
|
end
|
152
152
|
end
|
153
153
|
|
154
|
+
def create_role(role_name, attrs)
|
155
|
+
log(:info, "Create Role `#{role_name}`", :color => :cyan)
|
156
|
+
assume_role_policy_document = attrs.fetch(:assume_role_policy_document)
|
157
|
+
|
158
|
+
unless_dry_run do
|
159
|
+
params = {
|
160
|
+
:role_name => role_name,
|
161
|
+
:assume_role_policy_document => encode_document(assume_role_policy_document),
|
162
|
+
}
|
163
|
+
|
164
|
+
params[:path] = attrs[:path] if attrs[:path]
|
165
|
+
@iam.create_role(params)
|
166
|
+
end
|
167
|
+
|
168
|
+
new_role_attrs = {
|
169
|
+
:instance_profiles => [],
|
170
|
+
:assume_role_policy_document => assume_role_policy_document,
|
171
|
+
:policies => {}
|
172
|
+
}
|
173
|
+
|
174
|
+
new_role_attrs[:path] = attrs[:path] if attrs[:path]
|
175
|
+
new_role_attrs
|
176
|
+
end
|
177
|
+
|
178
|
+
def delete_role(role_name, instance_profile_names, attrs)
|
179
|
+
log(:info, "Delete Role `#{role_name}`", :color => :red)
|
180
|
+
|
181
|
+
unless_dry_run do
|
182
|
+
attrs[:policies].keys.each do |policy_name|
|
183
|
+
@iam.delete_role_policy(:role_name => role_name, :policy_name => policy_name)
|
184
|
+
end
|
185
|
+
|
186
|
+
instance_profile_names.each do |instance_profile_name|
|
187
|
+
@iam.remove_role_from_instance_profile(:instance_profile_name => instance_profile_name, :role_name => role_name)
|
188
|
+
end
|
189
|
+
|
190
|
+
@iam.delete_role(:role_name => role_name)
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
def add_role_to_instance_profiles(role_name, instance_profile_names)
|
195
|
+
log(:info, "Update Role `#{role_name}`", :color => :green)
|
196
|
+
log(:info, " add instance_profiles=#{instance_profile_names.join(',')}", :color => :green)
|
197
|
+
|
198
|
+
unless_dry_run do
|
199
|
+
instance_profile_names.each do |instance_profile_name|
|
200
|
+
@iam.add_role_to_instance_profile(:instance_profile_name => instance_profile_name, :role_name => role_name)
|
201
|
+
end
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
def remove_role_from_instance_profiles(role_name, instance_profile_names)
|
206
|
+
log(:info, "Update Role `#{role_name}`", :color => :green)
|
207
|
+
log(:info, " remove instance_profiles=#{instance_profile_names.join(',')}", :color => :green)
|
208
|
+
|
209
|
+
unless_dry_run do
|
210
|
+
instance_profile_names.each do |instance_profile_name|
|
211
|
+
@iam.remove_role_from_instance_profile(:instance_profile_name => instance_profile_name, :role_name => role_name)
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
def update_assume_role_policy(role_name, policy_document)
|
217
|
+
log(:info, "Update Role `#{role_name}` > AssumeRolePolicy", :color => :green)
|
218
|
+
log(:info, " #{policy_document.pretty_inspect.gsub("\n", "\n ").strip}", :color => :green)
|
219
|
+
|
220
|
+
unless_dry_run do
|
221
|
+
@iam.update_assume_role_policy(
|
222
|
+
:role_name => role_name,
|
223
|
+
:policy_document => encode_document(policy_document),
|
224
|
+
)
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
def create_instance_profile(instance_profile_name, attrs)
|
229
|
+
log(:info, "Create InstanceIrofile `#{instance_profile_name}`", :color => :cyan)
|
230
|
+
|
231
|
+
unless_dry_run do
|
232
|
+
params = {:instance_profile_name => instance_profile_name}
|
233
|
+
params[:path] = attrs[:path] if attrs[:path]
|
234
|
+
@iam.create_instance_profile(params)
|
235
|
+
end
|
236
|
+
|
237
|
+
new_instance_profile_attrs = {}
|
238
|
+
new_instance_profile_attrs[:path] = attrs[:path] if attrs[:path]
|
239
|
+
new_instance_profile_attrs
|
240
|
+
end
|
241
|
+
|
242
|
+
def delete_instance_profile(instance_profile_name, attrs, roles_in_instance_profile)
|
243
|
+
log(:info, "Delete InstanceProfile `#{instance_profile_name}`", :color => :red)
|
244
|
+
|
245
|
+
unless_dry_run do
|
246
|
+
roles_in_instance_profile.each do |role_name|
|
247
|
+
@iam.remove_role_from_instance_profile(:instance_profile_name => instance_profile_name, :role_name => role_name)
|
248
|
+
end
|
249
|
+
|
250
|
+
@iam.delete_instance_profile(:instance_profile_name => instance_profile_name)
|
251
|
+
end
|
252
|
+
end
|
253
|
+
|
154
254
|
def update_name(type, user_or_group_name, new_name)
|
155
255
|
log(:info, "Update #{Miam::Utils.camelize(type.to_s)} `#{user_or_group_name}`", :color => :green)
|
156
256
|
log(:info, " set name=#{new_name}", :color => :green)
|
data/lib/miam/dsl/context.rb
CHANGED
@@ -10,7 +10,7 @@ class Miam::DSL::Context
|
|
10
10
|
def initialize(path, options = {}, &block)
|
11
11
|
@path = path
|
12
12
|
@options = options
|
13
|
-
@result = {:users => {}, :groups => {}}
|
13
|
+
@result = {:users => {}, :groups => {}, :roles => {}, :instance_profiles => {}}
|
14
14
|
instance_eval(&block)
|
15
15
|
end
|
16
16
|
|
@@ -49,4 +49,25 @@ class Miam::DSL::Context
|
|
49
49
|
attrs = Miam::DSL::Context::Group.new(name, &block).result
|
50
50
|
@result[:groups][name] = group_options.merge(attrs)
|
51
51
|
end
|
52
|
+
|
53
|
+
def role(name, role_options = {}, &block)
|
54
|
+
name = name.to_s
|
55
|
+
|
56
|
+
if @result[:roles][name]
|
57
|
+
raise "Role `#{name}` is already defined"
|
58
|
+
end
|
59
|
+
|
60
|
+
attrs = Miam::DSL::Context::Role.new(name, &block).result
|
61
|
+
@result[:roles][name] = role_options.merge(attrs)
|
62
|
+
end
|
63
|
+
|
64
|
+
def instance_profile(name, instance_profile_options = {}, &block)
|
65
|
+
name = name.to_s
|
66
|
+
|
67
|
+
if @result[:instance_profiles][name]
|
68
|
+
raise "instance_profile `#{name}` is already defined"
|
69
|
+
end
|
70
|
+
|
71
|
+
@result[:instance_profiles][name] = instance_profile_options
|
72
|
+
end
|
52
73
|
end
|
@@ -1,6 +1,6 @@
|
|
1
1
|
class Miam::DSL::Context::Group
|
2
2
|
def initialize(name, &block)
|
3
|
-
@
|
3
|
+
@group_name = name
|
4
4
|
@result = {:policies => {}}
|
5
5
|
instance_eval(&block)
|
6
6
|
end
|
@@ -13,13 +13,13 @@ class Miam::DSL::Context::Group
|
|
13
13
|
name = name.to_s
|
14
14
|
|
15
15
|
if @result[:policies][name]
|
16
|
-
raise "Group `#{
|
16
|
+
raise "Group `#{@group_name}` > Policy `#{name}`: already defined"
|
17
17
|
end
|
18
18
|
|
19
19
|
policy_document = yield
|
20
20
|
|
21
21
|
unless policy_document.kind_of?(Hash)
|
22
|
-
raise "Group `#{
|
22
|
+
raise "Group `#{@group_name}` > Policy `#{name}`: wrong argument type #{policy_document.class} (expected Hash)"
|
23
23
|
end
|
24
24
|
|
25
25
|
@result[:policies][name] = policy_document
|
@@ -0,0 +1,51 @@
|
|
1
|
+
class Miam::DSL::Context::Role
|
2
|
+
def initialize(name, &block)
|
3
|
+
@role_name = name
|
4
|
+
@result = {:instance_profiles => [], :policies => {}}
|
5
|
+
instance_eval(&block)
|
6
|
+
end
|
7
|
+
|
8
|
+
def result
|
9
|
+
unless @result[:assume_role_policy_document]
|
10
|
+
raise "Role `#{@role_name}`: AssumeRolePolicyDocument is not defined"
|
11
|
+
end
|
12
|
+
|
13
|
+
@result
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def instance_profiles(*profiles)
|
19
|
+
@result[:instance_profiles].concat(profiles.map {|i| i.to_s })
|
20
|
+
end
|
21
|
+
|
22
|
+
def assume_role_policy_document
|
23
|
+
if @result[:assume_role_policy_document]
|
24
|
+
raise "Role `#{@role_name}` > AssumeRolePolicyDocument: already defined"
|
25
|
+
end
|
26
|
+
|
27
|
+
assume_role_policy_document = yield
|
28
|
+
|
29
|
+
unless assume_role_policy_document.kind_of?(Hash)
|
30
|
+
raise "Role `#{@role_name}` > AssumeRolePolicyDocument: wrong argument type #{policy_document.class} (expected Hash)"
|
31
|
+
end
|
32
|
+
|
33
|
+
@result[:assume_role_policy_document] = assume_role_policy_document
|
34
|
+
end
|
35
|
+
|
36
|
+
def policy(name)
|
37
|
+
name = name.to_s
|
38
|
+
|
39
|
+
if @result[:policies][name]
|
40
|
+
raise "Role `#{@role_name}` > Policy `#{name}`: already defined"
|
41
|
+
end
|
42
|
+
|
43
|
+
policy_document = yield
|
44
|
+
|
45
|
+
unless policy_document.kind_of?(Hash)
|
46
|
+
raise "Role `#{@role_name}` > Policy `#{name}`: wrong argument type #{policy_document.class} (expected Hash)"
|
47
|
+
end
|
48
|
+
|
49
|
+
@result[:policies][name] = policy_document
|
50
|
+
end
|
51
|
+
end
|