morpheus-cli 8.1.2 → 9.0.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.
@@ -0,0 +1,391 @@
1
+ require 'morpheus/cli/cli_command'
2
+
3
+ # This provides commands for authentication
4
+ # This also includes credential management.
5
+ class Morpheus::Cli::TokensCommand
6
+ include Morpheus::Cli::CliCommand
7
+ include Morpheus::Cli::AccountsHelper
8
+
9
+ set_command_name :'tokens'
10
+ set_command_description "View and manage API access tokens."
11
+ register_subcommands :list, :get, :add, :remove, :remove_all
12
+
13
+ def connect(opts)
14
+ @api_client = establish_remote_appliance_connection(opts)
15
+ @tokens_interface = @api_client.tokens
16
+ @accounts_interface = @api_client.accounts
17
+ @account_users_interface = @api_client.account_users
18
+ end
19
+
20
+ def handle(args)
21
+ handle_subcommand(args)
22
+ end
23
+
24
+ def list(args)
25
+ options = {}
26
+ params = {}
27
+ ref_ids = []
28
+ optparse = Morpheus::Cli::OptionParser.new do |opts|
29
+ opts.banner = subcommand_usage("[search]")
30
+ build_standard_list_options(opts, options)
31
+ opts.on("--client-id CLIENT", "Filter by Client ID. eg. morph-api, morph-cli") do |val|
32
+ params['clientId'] = val.to_s
33
+ end
34
+ opts.on("--name TOKEN", "Filter by name") do |val|
35
+ params['name'] = val.to_s
36
+ end
37
+ opts.on("--value TOKEN", "Filter by access token value") do |val|
38
+ params['token'] = val.to_s
39
+ end
40
+ opts.on("-u", "--user USER", "User username or ID") do |val|
41
+ options[:user] = val.to_s
42
+ end
43
+ opts.on("--user-id ID", String, "User ID") do |val|
44
+ params['userId'] = val.to_s
45
+ end
46
+ opts.footer = "List API access tokens."
47
+ end
48
+ optparse.parse!(args)
49
+ connect(options)
50
+ # verify_args!(args:args, optparse:optparse, count:0)
51
+ if args.count > 0
52
+ options[:phrase] = args.join(" ")
53
+ end
54
+ params.merge!(parse_list_options(options))
55
+ if options[:user]
56
+ user = find_user_by_username_or_id(nil, options[:user], {global:true})
57
+ return 1 if user.nil?
58
+ params['userId'] = user['id']
59
+ end
60
+ @tokens_interface.setopts(options)
61
+ if options[:dry_run]
62
+ print_dry_run @tokens_interface.dry.list(params)
63
+ return
64
+ end
65
+ json_response = @tokens_interface.list(params)
66
+ tokens = json_response['tokens']
67
+ render_response(json_response, options, 'tokens') do
68
+ print_h1 "Morpheus API Tokens", parse_list_subtitles(options), options
69
+ if tokens.empty?
70
+ print yellow,"No tokens found.",reset,"\n"
71
+ else
72
+ columns = token_columns.select {|k,v| ["ID", "Name", "Client ID", "Username", "Access Token", "Expiration", "TTL", "Date Created"].include?(k) }.upcase_keys!
73
+ #columns = token_columns.upcase_keys!
74
+ print as_pretty_table(tokens, columns, options)
75
+ print_results_pagination(json_response)
76
+ end
77
+ print reset,"\n"
78
+ end
79
+ end
80
+
81
+ def get(args)
82
+ params = {}
83
+ options = {}
84
+ optparse = Morpheus::Cli::OptionParser.new do |opts|
85
+ opts.banner = subcommand_usage("[id]")
86
+ opts.on("-u", "--user USER", "User username or ID") do |val|
87
+ options[:user] = val.to_s
88
+ end
89
+ opts.on("--user-id ID", String, "User ID") do |val|
90
+ params['userId'] = val.to_s
91
+ end
92
+ build_standard_get_options(opts, options)
93
+ opts.footer = <<-EOT
94
+ Get details about a specific token.
95
+ [token] is required. This is the id or name or value of the token.
96
+ EOT
97
+ end
98
+ optparse.parse!(args)
99
+ verify_args!(args:args, optparse:optparse, min:1)
100
+ connect(options)
101
+ if options[:user]
102
+ user = find_user_by_username_or_id(nil, options[:user], {global:true})
103
+ return 1 if user.nil?
104
+ params['userId'] = user['id']
105
+ end
106
+ id_list = parse_id_list(args).collect do |id|
107
+ if id.to_s =~ /\A\d{1,}\Z/
108
+ id
109
+ else
110
+ # Looking for a token by secret value? eg. "93cb5548-********""
111
+ if id.to_s =~ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
112
+ token = find_token_by_token(id, params['userId'])
113
+ if token
114
+ token['id']
115
+ else
116
+ return 1, "Token not found for '#{id[0..8]}********'"
117
+ end
118
+ else
119
+ token = find_token_by_name(id, params['userId'])
120
+ if token
121
+ token['id']
122
+ else
123
+ return 1, "Token not found for '#{id}'"
124
+ end
125
+ end
126
+ end
127
+ end
128
+ return run_command_for_each_arg(id_list) do |id|
129
+ _get(id, params, options)
130
+ end
131
+ end
132
+
133
+ def _get(id, params, options)
134
+ @tokens_interface.setopts(options)
135
+ if options[:dry_run]
136
+ print_dry_run @tokens_interface.dry.get(id, params)
137
+ return
138
+ end
139
+ json_response = @tokens_interface.get(id, params)
140
+ render_response(json_response, options, 'token') do
141
+ token = json_response['token']
142
+ print_h1 "Token Details", [], options
143
+ print cyan
144
+ print_description_list(token_columns, token)
145
+ print reset,"\n"
146
+ end
147
+ end
148
+
149
+ def add(args)
150
+ options = {}
151
+ params = {}
152
+
153
+ optparse = Morpheus::Cli::OptionParser.new do |opts|
154
+ opts.banner = subcommand_usage("[name]")
155
+ build_option_type_options(opts, options, add_token_option_types)
156
+ opts.on("-u", "--user USER", "User username or ID") do |val|
157
+ options[:user] = val.to_s
158
+ end
159
+ opts.on("--user-id ID", String, "User ID") do |val|
160
+ params['userId'] = val.to_s
161
+ end
162
+ build_standard_add_options(opts, options)
163
+ opts.footer = <<-EOT
164
+ Create a new token
165
+ EOT
166
+ end
167
+ optparse.parse!(args)
168
+ verify_args!(args:args, optparse:optparse, min:0, max:1)
169
+ connect(options)
170
+ if args[0]
171
+ options[:options]['name'] = args[0]
172
+ end
173
+ payload = {}
174
+ if options[:payload]
175
+ payload = options[:payload]
176
+ payload.deep_merge!({'token' => parse_passed_options(options)})
177
+ else
178
+ payload.deep_merge!({'token' => parse_passed_options(options)})
179
+ v_prompt = Morpheus::Cli::OptionTypes.prompt(add_token_option_types, options[:options], @api_client, options[:params])
180
+ params.deep_merge!(v_prompt)
181
+ payload['token'].deep_merge!(params)
182
+ end
183
+ if options[:user]
184
+ user = find_user_by_username_or_id(nil, options[:user], {global:true})
185
+ return 1 if user.nil?
186
+ params['userId'] = user['id']
187
+ end
188
+ @tokens_interface.setopts(options)
189
+ if options[:dry_run]
190
+ print_dry_run @tokens_interface.dry.create(payload, params)
191
+ return 0, nil
192
+ end
193
+ json_response = @tokens_interface.create(payload, params)
194
+ render_response(json_response, options, 'token') do
195
+ token = json_response['token']
196
+ # print_green_success "Created new token"
197
+ # print_green_success "Access Token: #{token['accessToken']}"
198
+ # print_green_success "Refresh Token: #{token['refreshToken']}"
199
+ # return _get(token["id"], {}, options)
200
+ print_green_success "Added token #{token['name'] || token['id']}"
201
+ # show new access and refresh tokens unmasked and in green
202
+ columns = token_columns
203
+ columns["Access Token"] = lambda {|it| "#{green}#{it['accessToken']}#{cyan}" }
204
+ columns["Refresh Token"] = lambda {|it| "#{green}#{it['refreshToken']}#{cyan}" }
205
+ print_h1 "New Token Details", [], options
206
+ print cyan
207
+ print_description_list(columns, token)
208
+ print reset,"\n"
209
+ end
210
+ end
211
+
212
+ def remove(args)
213
+ options = {}
214
+ params = {}
215
+ optparse = Morpheus::Cli::OptionParser.new do |opts|
216
+ opts.banner = subcommand_usage("[id list]")
217
+ opts.on("--client-id CLIENT", "Filter by Client ID. eg. morph-api, morph-cli") do |val|
218
+ params['clientId'] = val.to_s
219
+ end
220
+ opts.on("-u", "--user USER", "User username or ID") do |val|
221
+ options[:user] = val.to_s
222
+ end
223
+ opts.on("--user-id ID", String, "User ID") do |val|
224
+ params['userId'] = val.to_s
225
+ end
226
+ build_standard_remove_options(opts, options)
227
+ opts.footer = <<-EOT
228
+ Delete a token.
229
+ [id] is required. This is the id of a token.
230
+ EOT
231
+ end
232
+ optparse.parse!(args)
233
+ verify_args!(args:args, optparse:optparse, count:1)
234
+ connect(options)
235
+ if options[:user]
236
+ user = find_user_by_username_or_id(nil, options[:user], {global:true})
237
+ return 1 if user.nil?
238
+ params['userId'] = user['id']
239
+ end
240
+ token = find_token_by_name_or_id(args[0], params['userId'])
241
+ return 1, "Token not found" if token.nil?
242
+ parse_options(options, params)
243
+ confirm!("Are you sure you want to delete the token ID: #{token['id']} Value: #{token['maskedAccessToken']}?", options)
244
+ execute_api(@tokens_interface, :destroy, [token['id']], options) do |json_response|
245
+ print_green_success "Removed token #{token['maskedAccessToken']}"
246
+ end
247
+ end
248
+
249
+ def remove_all(args)
250
+ client_id = nil
251
+ options = {}
252
+ params = {}
253
+ optparse = Morpheus::Cli::OptionParser.new do |opts|
254
+ opts.banner = subcommand_usage("[id]")
255
+ opts.on("--client-id CLIENT", "Delete all tokens for a specific Client ID. eg. morph-api, morph-cli") do |val|
256
+ client_id = val.to_s
257
+ end
258
+ opts.on("-u", "--user USER", "User username or ID") do |val|
259
+ options[:user] = val.to_s
260
+ end
261
+ opts.on("--user-id ID", String, "User ID") do |val|
262
+ params['userId'] = val.to_s
263
+ end
264
+ build_standard_remove_options(opts, options)
265
+ opts.footer = <<-EOT
266
+ Delete many tokens at once.
267
+ [id list] is required. This is the list of token ids to be deleted
268
+ This command supports using --client-id CLIENT option instead of [id list]
269
+ EOT
270
+ end
271
+ optparse.parse!(args)
272
+ verify_args!(args:args, optparse:optparse)
273
+ connect(options)
274
+ if options[:user]
275
+ user = find_user_by_username_or_id(nil, options[:user], {global:true})
276
+ return 1 if user.nil?
277
+ params['userId'] = user['id']
278
+ end
279
+ id_list = parse_id_list(args)
280
+ if client_id
281
+ confirm!("Are you sure you want to perform this bulk delete tokens '#{client_id}'?", options)
282
+ params['clientId'] = client_id
283
+ execute_api(@tokens_interface, :destroy_all, [params], options) do |json_response|
284
+ print_green_success "Removed all your tokens for client #{client_id}"
285
+ end
286
+ elsif id_list && !id_list.empty?
287
+ confirm!("Are you sure you want to perform this bulk delete of #{id_list.size} tokens?", options)
288
+ params['id'] = id_list
289
+ execute_api(@tokens_interface, :destroy_all, [params], options) do |json_response|
290
+ print_green_success "Removed #{id_list.size} tokens"
291
+ end
292
+ else
293
+ raise_command_error "Bulk delete requires a list of ids"
294
+ end
295
+ end
296
+
297
+ protected
298
+
299
+ def token_columns
300
+ {
301
+ "ID" => lambda {|it| it['id'] },
302
+ "Name" => lambda {|it| it['name'] },
303
+ "Client ID" => lambda {|it| it['clientId'] },
304
+ "Username" => lambda {|it| it['username'] },
305
+ "Access Token" => lambda {|it| it['maskedAccessToken'] },
306
+ #"Refresh Token" => lambda {|it| it['maskedRefreshToken'] },
307
+ "Scope" => lambda {|it| it['scope'] },
308
+ "Expiration" => lambda {|it| format_local_dt(it['expiration']) },
309
+ "TTL" => lambda {|it|
310
+ if it['expiration']
311
+ expires_on = parse_time(it['expiration'])
312
+ if expires_on && expires_on < Time.now
313
+ "Expired"
314
+ else
315
+ it['expiration'] ? (format_duration(it['expiration']) rescue '') : ''
316
+ end
317
+ end
318
+ },
319
+ # "Date Created" => lambda {|it| format_local_dt(it['dateCreated']) },
320
+ }
321
+ end
322
+
323
+ def add_token_option_types
324
+ [
325
+ {'fieldName' => 'name', 'fieldLabel' => 'Name', 'type' => 'text', 'description' => "Optional display name for this access token"},
326
+ {'fieldName' => 'clientId', 'fieldLabel' => 'Client ID', 'type' => 'select', 'optionSource' => 'clients', 'required' => true, 'defaultValue' => 'morph-api'},
327
+ ]
328
+ end
329
+
330
+ def find_token_by_name_or_id(val, user_id=nil)
331
+ if val.to_s =~ /\A\d{1,}\Z/
332
+ return find_token_by_id(val, user_id=nil)
333
+ else
334
+ return find_token_by_name(val, user_id=nil)
335
+ end
336
+ end
337
+
338
+ def find_token_by_id(id, user_id=nil)
339
+ params = {}
340
+ params['userId'] = user_id if user_id
341
+ begin
342
+ json_response = @tokens_interface.get(id.to_i, params)
343
+ return json_response['token']
344
+ rescue RestClient::Exception => e
345
+ if e.response && e.response.code == 404
346
+ print_red_alert "Token not found by id '#{id}'" + (user_id ? " for user #{user_id}" : "")
347
+ else
348
+ raise e
349
+ end
350
+ end
351
+ end
352
+
353
+ def find_token_by_name(name, user_id=nil)
354
+ params = {name: name.to_s}
355
+ params['userId'] = user_id if user_id
356
+ json_response = @tokens_interface.list(params)
357
+ tokens = json_response['tokens']
358
+ if tokens.empty?
359
+ print_red_alert "Token not found by name '#{name}'" + (user_id ? " for user #{user_id}" : "")
360
+ return nil
361
+ elsif tokens.size > 1
362
+ print_red_alert "#{tokens.size} tokens found matching '#{name}'" + (user_id ? " for user #{user_id}" : "")
363
+ puts_error as_pretty_table(tokens, [:id, :name], {color:red})
364
+ print_red_alert "Try using ID instead"
365
+ print reset,"\n"
366
+ return nil
367
+ else
368
+ return tokens[0]
369
+ end
370
+ end
371
+
372
+ def find_token_by_token(value, user_id=nil)
373
+ params = {token: value.to_s}
374
+ params['userId'] = user_id if user_id
375
+ json_response = @tokens_interface.list(params)
376
+ tokens = json_response['tokens']
377
+ if tokens.empty?
378
+ print_red_alert "Tokens not found by value" + (user_id ? " for user #{user_id}" : "")
379
+ return nil
380
+ elsif tokens.size > 1
381
+ print_red_alert "#{tokens.size} tokens found matching '#{value}'" + (user_id ? " for user #{user_id}" : "")
382
+ puts_error as_pretty_table(tokens, [:id, :'accessToken'], {color:red})
383
+ print_red_alert "Try using ID instead"
384
+ print reset,"\n"
385
+ return nil
386
+ else
387
+ return tokens[0]
388
+ end
389
+ end
390
+
391
+ end
@@ -726,11 +726,24 @@ class Morpheus::Cli::Workflows
726
726
  # prompt to workflow optionTypes for customOptions
727
727
  custom_options = nil
728
728
  if workflow['optionTypes'] && workflow['optionTypes'].size() > 0
729
+ # Clone to avoid mutating workflow data, and clear fieldContext so that
730
+ # accumulated results remain flat (not nested under 'customOptions') when
731
+ # passed as params to dependent option source API calls. If fieldContext
732
+ # were left as 'customOptions', option_params would be sent to the options
733
+ # API as customOptions[zoneId]=2 instead of the expected flat zoneId=2,
734
+ # causing dependent option lists (e.g. Resource Pools) to return empty.
729
735
  custom_option_types = workflow['optionTypes'].collect {|it|
730
- it['fieldContext'] = 'customOptions'
731
- it
736
+ ot = it.clone
737
+ ot.delete('fieldContext')
738
+ ot
732
739
  }
733
- custom_options = Morpheus::Cli::OptionTypes.prompt(custom_option_types, options[:options], @api_client, {})
740
+ # Support both -O fieldName=value AND the legacy -O customOptions.fieldName=value syntax.
741
+ prompt_options = (options[:options] || {}).dup
742
+ if prompt_options['customOptions'].is_a?(Hash)
743
+ prompt_options.merge!(prompt_options.delete('customOptions'))
744
+ end
745
+ prompt_results = Morpheus::Cli::OptionTypes.prompt(custom_option_types, prompt_options, @api_client, {})
746
+ custom_options = {'customOptions' => prompt_results} unless prompt_results.empty?
734
747
  end
735
748
  job_payload = {}
736
749
  job_payload.deep_merge!(params)
@@ -63,11 +63,11 @@ module Morpheus::Cli::InfrastructureHelper
63
63
  @subnet_types_interface
64
64
  end
65
65
 
66
- def find_group_by_name_or_id(val)
66
+ def find_group_by_name_or_id(val, include_tenants=true)
67
67
  if val.to_s =~ /\A\d{1,}\Z/
68
68
  return find_group_by_id(val)
69
69
  else
70
- return find_group_by_name(val)
70
+ return find_group_by_name(val, include_tenants)
71
71
  end
72
72
  end
73
73
 
@@ -85,21 +85,29 @@ module Morpheus::Cli::InfrastructureHelper
85
85
  end
86
86
  end
87
87
 
88
- def find_group_by_name(name)
89
- json_results = groups_interface.list({name: name})
90
- if json_results['groups'].empty?
88
+ def find_group_by_name(name, include_tenants=false)
89
+ params = {name: name}
90
+ params['includeTenants'] = true if include_tenants
91
+ groups = groups_interface.list(params)['groups']
92
+ if groups.empty?
91
93
  print_red_alert "Group not found by name #{name}"
92
94
  exit 1
95
+ elsif groups.size > 1
96
+ print_red_alert "Multiple groups exist with the name '#{name}'"
97
+ print_error "\n"
98
+ puts_error as_pretty_table(groups, [:id, :name], {color:red})
99
+ print_red_alert "Try using ID instead"
100
+ print_error reset,"\n"
101
+ exit 1
93
102
  end
94
- group = json_results['groups'][0]
95
- return group
103
+ return groups[0]
96
104
  end
97
105
 
98
- def find_cloud_by_name_or_id(val)
106
+ def find_cloud_by_name_or_id(val, include_tenants=false)
99
107
  if val.to_s =~ /\A\d{1,}\Z/
100
108
  return find_cloud_by_id(val)
101
109
  else
102
- return find_cloud_by_name(val)
110
+ return find_cloud_by_name(val, include_tenants)
103
111
  end
104
112
  end
105
113
 
@@ -113,14 +121,22 @@ module Morpheus::Cli::InfrastructureHelper
113
121
  return cloud
114
122
  end
115
123
 
116
- def find_cloud_by_name(name)
117
- json_results = clouds_interface.list({name: name})
118
- if json_results['zones'].empty?
124
+ def find_cloud_by_name(name, include_tenants=false)
125
+ params = {name: name}
126
+ params['includeTenants'] = true if include_tenants
127
+ clouds = clouds_interface.list(params)['zones']
128
+ if clouds.empty?
119
129
  print_red_alert "Cloud not found by name #{name}"
120
130
  exit 1
131
+ elsif clouds.size > 1
132
+ print_red_alert "Multiple clouds exist with the name '#{name}'"
133
+ print_error "\n"
134
+ puts_error as_pretty_table(clouds, [:id, :name], {color:red})
135
+ print_red_alert "Try using ID instead"
136
+ print_error reset,"\n"
137
+ exit 1
121
138
  end
122
- cloud = json_results['zones'][0]
123
- return cloud
139
+ return clouds[0]
124
140
  end
125
141
 
126
142
  def get_available_cloud_types(refresh=false, params = {})
@@ -225,11 +225,11 @@ module Morpheus::Cli::ProvisioningHelper
225
225
  end
226
226
  end
227
227
 
228
- def find_instance_by_name_or_id(val)
228
+ def find_instance_by_name_or_id(val, include_tenants=false)
229
229
  if val.to_s =~ /\A\d{1,}\Z/
230
230
  return find_instance_by_id(val)
231
231
  else
232
- return find_instance_by_name(val)
232
+ return find_instance_by_name(val, include_tenants)
233
233
  end
234
234
  end
235
235
 
@@ -247,14 +247,21 @@ module Morpheus::Cli::ProvisioningHelper
247
247
  end
248
248
  end
249
249
 
250
- def find_instance_by_name(name)
251
- json_results = instances_interface.list({name: name.to_s})
252
- if json_results['instances'].empty?
250
+ def find_instance_by_name(name, include_tenants=false)
251
+ params = {name: name.to_s}
252
+ params['includeTenants'] = true if include_tenants
253
+ instances = instances_interface.list(params)['instances']
254
+ if instances.empty?
253
255
  print_red_alert "Instance not found by name #{name}"
254
256
  exit 1
257
+ elsif instances.size > 1
258
+ print_red_alert "Multiple Instances exist with the name '#{name}'"
259
+ puts_error as_pretty_table(instances, [:id, :name], {color:red})
260
+ print_red_alert "Try using ID instead"
261
+ print_error reset,"\n"
262
+ exit 1
255
263
  end
256
- instance = json_results['instances'][0]
257
- return instance
264
+ return instances[0]
258
265
  end
259
266
 
260
267
  def parse_instance_id_list(id_list)
@@ -1056,6 +1063,13 @@ module Morpheus::Cli::ProvisioningHelper
1056
1063
  end
1057
1064
  end
1058
1065
 
1066
+ # Fix any hugepages option type is using on/off which
1067
+ if payload['config']
1068
+ if payload['config']['hugepages'] == "off"
1069
+ payload['config'].delete('hugepages')
1070
+ end
1071
+ end
1072
+
1059
1073
  return payload
1060
1074
  end
1061
1075
 
@@ -1,6 +1,6 @@
1
1
 
2
2
  module Morpheus
3
3
  module Cli
4
- VERSION = "8.1.2"
4
+ VERSION = "9.0.0"
5
5
  end
6
6
  end
@@ -0,0 +1,26 @@
1
+ require 'test/unit'
2
+ $LOAD_PATH.unshift(File.expand_path('../../lib', __dir__))
3
+ require 'morpheus/api/systems_interface'
4
+
5
+ class SystemsInterfaceTest < Test::Unit::TestCase
6
+
7
+ def setup
8
+ @systems_interface = Morpheus::SystemsInterface.new(access_token: 'token', url: 'https://example.test', verify_ssl: false)
9
+ end
10
+
11
+ def test_list_network_server_update_definitions_builds_expected_request
12
+ request = @systems_interface.dry.list_network_server_update_definitions(2, 3, {phrase: 'fw'})
13
+ assert_equal :get, request[:method]
14
+ assert_equal 'https://example.test/api/infrastructure/systems/2/network-servers/3/update-definitions', request[:url]
15
+ assert_equal({phrase: 'fw'}, request[:params])
16
+ end
17
+
18
+ def test_apply_network_server_update_definition_builds_expected_request
19
+ request = @systems_interface.dry.apply_network_server_update_definition(2, 3, 4, {dryRun: true}, {foo: 'bar'})
20
+ assert_equal :post, request[:method]
21
+ assert_equal 'https://example.test/api/infrastructure/systems/2/network-servers/3/update-definitions/4', request[:url]
22
+ assert_equal({foo: 'bar'}, request[:params])
23
+ assert_equal({dryRun: true}.to_json, request[:payload])
24
+ end
25
+
26
+ end