morpheus-cli 9.0.2 → 9.1.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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/Dockerfile +1 -1
  3. data/lib/morpheus/api/affinity_groups_interface.rb +46 -0
  4. data/lib/morpheus/api/api_client.rb +13 -0
  5. data/lib/morpheus/api/clouds_interface.rb +42 -0
  6. data/lib/morpheus/api/clusters_interface.rb +7 -1
  7. data/lib/morpheus/api/distributed_workers_interface.rb +41 -0
  8. data/lib/morpheus/api/host_vm_groups_interface.rb +37 -0
  9. data/lib/morpheus/cli/commands/affinity_groups_command.rb +554 -0
  10. data/lib/morpheus/cli/commands/backups_command.rb +1 -1
  11. data/lib/morpheus/cli/commands/clouds.rb +421 -29
  12. data/lib/morpheus/cli/commands/clusters.rb +37 -39
  13. data/lib/morpheus/cli/commands/distributed_workers_command.rb +282 -0
  14. data/lib/morpheus/cli/commands/host_vm_groups_command.rb +423 -0
  15. data/lib/morpheus/cli/mixins/affinity_helper.rb +118 -0
  16. data/lib/morpheus/cli/mixins/infrastructure_helper.rb +137 -0
  17. data/lib/morpheus/cli/option_types.rb +36 -1
  18. data/lib/morpheus/cli/version.rb +1 -1
  19. data/test/api/affinity_groups_interface_test.rb +43 -0
  20. data/test/api/clouds_interface_test.rb +67 -0
  21. data/test/api/host_vm_groups_interface_test.rb +35 -0
  22. data/test/cli/affinity_groups_cloud_cluster_scope_test.rb +270 -0
  23. data/test/cli/affinity_groups_cluster_scope_test.rb +171 -0
  24. data/test/cli/affinity_groups_test.rb +57 -0
  25. data/test/cli/affinity_groups_violations_scope_test.rb +141 -0
  26. data/test/cli/affinity_helper_test.rb +367 -0
  27. data/test/cli/backups_test.rb +212 -0
  28. data/test/cli/clusters_uuid_test.rb +131 -0
  29. data/test/cli/distributed_workers_test.rb +68 -0
  30. data/test/cli/host_vm_groups_cluster_scope_test.rb +187 -0
  31. data/test/cli/host_vm_groups_test.rb +63 -0
  32. data/test/cli/infrastructure_helper_cluster_resolver_test.rb +250 -0
  33. metadata +37 -2
@@ -0,0 +1,423 @@
1
+ require 'morpheus/cli/cli_command'
2
+
3
+ class Morpheus::Cli::HostVmGroupsCommand
4
+ include Morpheus::Cli::CliCommand
5
+ include Morpheus::Cli::ProvisioningHelper
6
+ include Morpheus::Cli::WhoamiHelper
7
+ include Morpheus::Cli::InfrastructureHelper
8
+ include Morpheus::Cli::AffinityHelper
9
+
10
+ set_command_name :'host-vm-groups'
11
+
12
+ register_subcommands :list, :get, :add, :update, :remove
13
+
14
+ def connect(opts)
15
+ @api_client = establish_remote_appliance_connection(opts)
16
+ @host_vm_groups_interface = @api_client.host_vm_groups
17
+ @clouds_interface = @api_client.clouds
18
+ @clusters_interface = @api_client.clusters
19
+ @cloud_resource_pools_interface = @api_client.cloud_resource_pools
20
+ end
21
+
22
+ def handle(args)
23
+ handle_subcommand(args)
24
+ end
25
+
26
+ def list(args)
27
+ options = {}
28
+ params = {}
29
+ optparse = Morpheus::Cli::OptionParser.new do |opts|
30
+ opts.banner = subcommand_usage()
31
+ opts.on('--cloud CLOUD', String, "Filter by cloud name or id") do |val|
32
+ options[:cloud] = val
33
+ end
34
+ opts.on('--cluster CLUSTER', String, "Filter by cluster name or id") do |val|
35
+ options[:cluster] = val
36
+ end
37
+ build_standard_list_options(opts, options)
38
+ opts.footer = "List host/VM groups.\n" \
39
+ "Use --cloud or --cluster to scope results. Without a scope all groups visible to the user are listed."
40
+ end
41
+ optparse.parse!(args)
42
+ verify_args!(args:args, optparse:optparse, count:0)
43
+ connect(options)
44
+
45
+ if options[:cloud] && options[:cluster]
46
+ cloud = find_cloud_by_name_or_id(options[:cloud])
47
+ return 1 if cloud.nil?
48
+ pool = find_resource_pool_cluster_by_name_or_id(cloud, options[:cluster])
49
+ return 1 if pool.nil?
50
+ params.merge!(parse_list_options(options))
51
+ params['poolId'] = pool['id']
52
+ @clouds_interface.setopts(options)
53
+ if options[:dry_run]
54
+ print_dry_run @clouds_interface.dry.list_host_vm_groups(cloud['id'], params)
55
+ return
56
+ end
57
+ json_response = @clouds_interface.list_host_vm_groups(cloud['id'], params)
58
+ render_response(json_response, options, 'hostVmGroups') do
59
+ rows = json_response['hostVmGroups']
60
+ print_h1 "Host/VM Groups: #{cloud['name']} / #{pool['name']}", parse_list_subtitles(options), options
61
+ if rows.empty?
62
+ print cyan, "No host/VM groups found.", reset, "\n"
63
+ else
64
+ print as_pretty_table(rows, host_vm_group_list_columns, options)
65
+ print_results_pagination(json_response)
66
+ end
67
+ print reset, "\n"
68
+ end
69
+ elsif options[:cloud]
70
+ cloud = find_cloud_by_name_or_id(options[:cloud])
71
+ return 1 if cloud.nil?
72
+ params.merge!(parse_list_options(options))
73
+ @clouds_interface.setopts(options)
74
+ if options[:dry_run]
75
+ print_dry_run @clouds_interface.dry.list_host_vm_groups(cloud['id'], params)
76
+ return
77
+ end
78
+ json_response = @clouds_interface.list_host_vm_groups(cloud['id'], params)
79
+ render_response(json_response, options, 'hostVmGroups') do
80
+ rows = json_response['hostVmGroups']
81
+ print_h1 "Host/VM Groups: #{cloud['name']}", parse_list_subtitles(options), options
82
+ if rows.empty?
83
+ print cyan, "No host/VM groups found.", reset, "\n"
84
+ else
85
+ print as_pretty_table(rows, host_vm_group_list_columns, options)
86
+ print_results_pagination(json_response)
87
+ end
88
+ print reset, "\n"
89
+ end
90
+ elsif options[:cluster]
91
+ cluster_scope = find_cluster_list_scope_by_name_or_id(options[:cluster])
92
+ return 1 if cluster_scope.nil?
93
+ params.merge!(parse_list_options(options))
94
+ if cluster_scope[:type] == :managed_cluster
95
+ cluster = cluster_scope[:cluster]
96
+ @clusters_interface.setopts(options)
97
+ if options[:dry_run]
98
+ print_dry_run @clusters_interface.dry.list_host_vm_groups(cluster['id'], params)
99
+ return
100
+ end
101
+ json_response = @clusters_interface.list_host_vm_groups(cluster['id'], params)
102
+ scope_name = cluster['name']
103
+ else
104
+ cloud = cluster_scope[:cloud]
105
+ pool = cluster_scope[:pool]
106
+ params['poolId'] = pool['id']
107
+ @clouds_interface.setopts(options)
108
+ if options[:dry_run]
109
+ print_dry_run @clouds_interface.dry.list_host_vm_groups(cloud['id'], params)
110
+ return
111
+ end
112
+ json_response = @clouds_interface.list_host_vm_groups(cloud['id'], params)
113
+ scope_name = "#{cloud['name']} / #{pool['name']}"
114
+ end
115
+ render_response(json_response, options, 'hostVmGroups') do
116
+ rows = json_response['hostVmGroups']
117
+ print_h1 "Host/VM Groups: #{scope_name}", parse_list_subtitles(options), options
118
+ if rows.empty?
119
+ print cyan, "No host/VM groups found.", reset, "\n"
120
+ else
121
+ print as_pretty_table(rows, host_vm_group_list_columns, options)
122
+ print_results_pagination(json_response)
123
+ end
124
+ print reset, "\n"
125
+ end
126
+ else
127
+ # No top-level list route; /api/host-vm-groups exposes only /{id}.
128
+ print_error Morpheus::Terminal.angry_prompt
129
+ puts_error "#{command_name} list requires a scope: pass --cloud CLOUD or --cluster CLUSTER\n#{optparse}"
130
+ return 1, "scope required"
131
+ end
132
+ return 0, nil
133
+ end
134
+
135
+ def get(args)
136
+ options = {}
137
+ optparse = Morpheus::Cli::OptionParser.new do |opts|
138
+ opts.banner = subcommand_usage("[id]")
139
+ build_standard_get_options(opts, options)
140
+ opts.footer = "Get details about a host/VM group.\n" \
141
+ "[id] is required. This is the numeric id of the host/VM group."
142
+ end
143
+ optparse.parse!(args)
144
+ verify_args!(args:args, optparse:optparse, count:1)
145
+ connect(options)
146
+
147
+ id = args[0]
148
+ params = {}
149
+ params.merge!(parse_query_options(options))
150
+ @host_vm_groups_interface.setopts(options)
151
+ if options[:dry_run]
152
+ print_dry_run @host_vm_groups_interface.dry.get(id, params)
153
+ return
154
+ end
155
+ json_response = @host_vm_groups_interface.get(id, params)
156
+ render_response(json_response, options, 'hostVmGroup') do
157
+ row = json_response['hostVmGroup']
158
+ print_h1 "Host/VM Group Details", [], options
159
+ print_description_list(host_vm_group_detail_columns, row)
160
+ if row['servers'] && row['servers'].size > 0
161
+ print_h2 "Servers", options
162
+ print as_pretty_table(row['servers'], [:id, :name], options)
163
+ end
164
+ print reset, "\n"
165
+ end
166
+ return 0, nil
167
+ end
168
+
169
+ def add(args)
170
+ options = {}
171
+ optparse = Morpheus::Cli::OptionParser.new do |opts|
172
+ opts.banner = subcommand_usage("[options]")
173
+ opts.on('--cloud CLOUD', String, "Cloud name or id") do |val|
174
+ options[:options] ||= {}
175
+ options[:options]['cloudId'] = val
176
+ end
177
+ opts.on('--cluster CLUSTER', String, "Cluster name or id") do |val|
178
+ options[:options] ||= {}
179
+ options[:options]['clusterId'] = val
180
+ end
181
+ opts.on('--servers ID1,ID2', Array, "Server ids to add to the group") do |val|
182
+ options[:options] ||= {}
183
+ options[:options]['servers'] = val.map {|s| {'id' => s.to_i}}
184
+ end
185
+ add_perms_options(opts, options, ['plans'])
186
+ build_standard_add_options(opts, options)
187
+ opts.footer = "Create a new host/VM group.\n" \
188
+ "Specify --cloud or --cluster to scope the group."
189
+ end
190
+ optparse.parse!(args)
191
+ verify_args!(args:args, optparse:optparse, count:0)
192
+ connect(options)
193
+
194
+ begin
195
+ if options[:payload]
196
+ payload = options[:payload]
197
+ payload.deep_merge!(options[:options].reject {|k,v| k.is_a?(Symbol) }) if options[:options]
198
+ else
199
+ options[:options] ||= {}
200
+ host_vm_group = Morpheus::Cli::OptionTypes.prompt(add_host_vm_group_option_types, options[:options], @api_client, {})
201
+
202
+ unless options[:options].key?('servers')
203
+ server_prompt = Morpheus::Cli::OptionTypes.prompt([{
204
+ 'fieldName' => 'servers', 'fieldLabel' => 'Server', 'type' => 'multiTypeahead',
205
+ 'optionSource' => 'searchServers', 'searchParameter' => 'phrase',
206
+ 'description' => 'Select servers to add to the group.'
207
+ }], options[:options], @api_client, {})
208
+ host_vm_group['servers'] = server_prompt['servers'] if server_prompt['servers']
209
+ else
210
+ host_vm_group['servers'] = options[:options]['servers']
211
+ end
212
+
213
+ perms = prompt_permissions(options, is_master_account ? ['plans'] : ['plans', 'visibility', 'tenants'])
214
+ host_vm_group['resourcePermissions'] = perms['resourcePermissions'] unless perms['resourcePermissions'].nil?
215
+ host_vm_group['tenants'] = perms['tenantPermissions'] unless perms['tenantPermissions'].nil? || perms['tenantPermissions']['accounts'].empty?
216
+ host_vm_group['visibility'] = perms['resourcePool']['visibility'] if !perms['resourcePool'].nil? && !perms['resourcePool']['visibility'].nil?
217
+
218
+ payload = {'hostVmGroup' => host_vm_group}
219
+ end
220
+
221
+ # Create is scoped -- POST /api/clusters/{id}/host-vm-groups or
222
+ # /api/zones/{id}/host-vm-groups. There is no top-level create route.
223
+ scope_cloud = options[:options] && options[:options]['cloudId']
224
+ scope_cluster = options[:options] && options[:options]['clusterId']
225
+ if scope_cloud && scope_cluster
226
+ cloud = find_cloud_by_name_or_id(scope_cloud)
227
+ return 1 if cloud.nil?
228
+ pool = find_resource_pool_cluster_by_name_or_id(cloud, scope_cluster)
229
+ return 1 if pool.nil?
230
+ if payload['hostVmGroup'].is_a?(Hash)
231
+ payload['hostVmGroup'].delete('cloudId')
232
+ payload['hostVmGroup'].delete('clusterId')
233
+ payload['hostVmGroup']['pool'] = {'id' => pool['id']}
234
+ end
235
+ @clouds_interface.setopts(options)
236
+ if options[:dry_run]
237
+ print_dry_run @clouds_interface.dry.create_host_vm_group(cloud['id'], payload)
238
+ return
239
+ end
240
+ json_response = @clouds_interface.create_host_vm_group(cloud['id'], payload)
241
+ elsif scope_cloud
242
+ cloud = find_cloud_by_name_or_id(scope_cloud)
243
+ return 1 if cloud.nil?
244
+ payload['hostVmGroup'].delete('cloudId') if payload['hostVmGroup'].is_a?(Hash)
245
+ @clouds_interface.setopts(options)
246
+ if options[:dry_run]
247
+ print_dry_run @clouds_interface.dry.create_host_vm_group(cloud['id'], payload)
248
+ return
249
+ end
250
+ json_response = @clouds_interface.create_host_vm_group(cloud['id'], payload)
251
+ elsif scope_cluster
252
+ cluster_scope = find_cluster_list_scope_by_name_or_id(scope_cluster)
253
+ return 1 if cluster_scope.nil?
254
+ if cluster_scope[:type] == :managed_cluster
255
+ cluster = cluster_scope[:cluster]
256
+ payload['hostVmGroup'].delete('clusterId') if payload['hostVmGroup'].is_a?(Hash)
257
+ @clusters_interface.setopts(options)
258
+ if options[:dry_run]
259
+ print_dry_run @clusters_interface.dry.create_host_vm_group(cluster['id'], payload)
260
+ return
261
+ end
262
+ json_response = @clusters_interface.create_host_vm_group(cluster['id'], payload)
263
+ else
264
+ cloud = cluster_scope[:cloud]
265
+ pool = cluster_scope[:pool]
266
+ if payload['hostVmGroup'].is_a?(Hash)
267
+ payload['hostVmGroup'].delete('clusterId')
268
+ payload['hostVmGroup']['pool'] = {'id' => pool['id']}
269
+ end
270
+ @clouds_interface.setopts(options)
271
+ if options[:dry_run]
272
+ print_dry_run @clouds_interface.dry.create_host_vm_group(cloud['id'], payload)
273
+ return
274
+ end
275
+ json_response = @clouds_interface.create_host_vm_group(cloud['id'], payload)
276
+ end
277
+ else
278
+ print_error Morpheus::Terminal.angry_prompt
279
+ puts_error "#{command_name} add requires a scope: pass --cloud CLOUD or --cluster CLUSTER\n#{optparse}"
280
+ return 1, "scope required"
281
+ end
282
+ render_response(json_response, options, 'hostVmGroup') do
283
+ row = json_response['hostVmGroup']
284
+ print_green_success "Created host/VM group #{row ? row['name'] : ''}"
285
+ end
286
+ return 0, nil
287
+ rescue RestClient::Exception => e
288
+ handle_affinity_rest_exception(e, options)
289
+ exit 1
290
+ end
291
+ end
292
+
293
+ def update(args)
294
+ options = {}
295
+ optparse = Morpheus::Cli::OptionParser.new do |opts|
296
+ opts.banner = subcommand_usage("[id] [options]")
297
+ build_option_type_options(opts, options, update_host_vm_group_option_types)
298
+ opts.on('--servers ID1,ID2', Array, "Server ids") do |val|
299
+ options[:options] ||= {}
300
+ options[:options]['servers'] = val.map {|s| {'id' => s.to_i}}
301
+ end
302
+ add_perms_options(opts, options, ['plans'])
303
+ build_standard_update_options(opts, options)
304
+ opts.footer = "Update a host/VM group.\n" \
305
+ "[id] is required. This is the numeric id of the host/VM group."
306
+ end
307
+ optparse.parse!(args)
308
+ verify_args!(args:args, optparse:optparse, count:1)
309
+ connect(options)
310
+
311
+ begin
312
+ id = args[0]
313
+ if options[:payload]
314
+ payload = options[:payload]
315
+ payload.deep_merge!(options[:options].reject {|k,v| k.is_a?(Symbol) }) if options[:options]
316
+ else
317
+ payload = {'hostVmGroup' => {}}
318
+ v_prompt = Morpheus::Cli::OptionTypes.no_prompt(update_host_vm_group_option_types, options[:options], @api_client, {})
319
+ payload.deep_merge!({'hostVmGroup' => v_prompt})
320
+
321
+ perms = prompt_permissions(options, is_master_account ? ['plans'] : ['plans', 'visibility', 'tenants'])
322
+ payload['hostVmGroup']['permissions'] = perms['resourcePermissions'] if perms['resourcePermissions']
323
+ payload['hostVmGroup']['visibility'] = perms['resourcePool']['visibility'] if !perms['resourcePool'].nil? && !perms['resourcePool']['visibility'].nil?
324
+
325
+ if options[:options]
326
+ payload.deep_merge!({'hostVmGroup' => options[:options].reject {|k,v| k.is_a?(Symbol) || payload['hostVmGroup'].key?(k) }})
327
+ end
328
+ if payload['hostVmGroup'].empty?
329
+ raise_command_error "Specify at least one option to update.\n#{optparse}"
330
+ end
331
+ end
332
+
333
+ @host_vm_groups_interface.setopts(options)
334
+ if options[:dry_run]
335
+ print_dry_run @host_vm_groups_interface.dry.update(id, payload)
336
+ return
337
+ end
338
+ json_response = @host_vm_groups_interface.update(id, payload)
339
+ render_response(json_response, options, 'hostVmGroup') do
340
+ updated = json_response['hostVmGroup']
341
+ print_green_success "Updated host/VM group #{updated ? updated['name'] : id}"
342
+ end
343
+ return 0, nil
344
+ rescue RestClient::Exception => e
345
+ handle_affinity_rest_exception(e, options)
346
+ exit 1
347
+ end
348
+ end
349
+
350
+ def remove(args)
351
+ options = {}
352
+ optparse = Morpheus::Cli::OptionParser.new do |opts|
353
+ opts.banner = subcommand_usage("[id]")
354
+ build_standard_remove_options(opts, options)
355
+ opts.footer = "Delete a host/VM group.\n" \
356
+ "[id] is required. This is the numeric id of the host/VM group."
357
+ end
358
+ optparse.parse!(args)
359
+ verify_args!(args:args, optparse:optparse, count:1)
360
+ connect(options)
361
+
362
+ id = args[0]
363
+ unless options[:yes] || ::Morpheus::Cli::OptionTypes.confirm("Are you sure you want to delete host/VM group '#{id}'?", options)
364
+ return 9, "aborted command"
365
+ end
366
+
367
+ @host_vm_groups_interface.setopts(options)
368
+ if options[:dry_run]
369
+ print_dry_run @host_vm_groups_interface.dry.destroy(id)
370
+ return
371
+ end
372
+ begin
373
+ json_response = @host_vm_groups_interface.destroy(id)
374
+ render_response(json_response, options) do
375
+ print_green_success "Removed host/VM group #{id}"
376
+ end
377
+ return 0, nil
378
+ rescue RestClient::Exception => e
379
+ handle_affinity_rest_exception(e, options)
380
+ exit 1
381
+ end
382
+ end
383
+
384
+ private
385
+
386
+ def host_vm_group_list_columns
387
+ {
388
+ "ID" => 'id',
389
+ "Name" => 'name',
390
+ "Type" => lambda {|it| format_host_vm_group_type(it['type']) },
391
+ "Resource Pool" => lambda {|it| it['pool'] ? (it['pool']['name'] || it['pool']['id']) : '' },
392
+ "Servers" => lambda {|it| (it['servers'] || []).size },
393
+ }.upcase_keys!
394
+ end
395
+
396
+ def host_vm_group_detail_columns
397
+ {
398
+ "ID" => 'id',
399
+ "Name" => 'name',
400
+ "Type" => lambda {|it| format_host_vm_group_type(it['type']) },
401
+ "Ref Type" => 'refType',
402
+ "Ref ID" => 'refId',
403
+ "Resource Pool" => lambda {|it| it['pool'] ? (it['pool']['name'] || it['pool']['id']) : '' },
404
+ "Visibility" => lambda {|it| it['visibility'].to_s.capitalize },
405
+ "Servers" => lambda {|it| (it['servers'] || []).size },
406
+ "Source" => 'source',
407
+ }
408
+ end
409
+
410
+ def add_host_vm_group_option_types
411
+ [
412
+ {'fieldName' => 'name', 'fieldLabel' => 'Name', 'type' => 'text', 'required' => true},
413
+ host_vm_group_type_option_type(required: true, default: 'HOST_GROUP'),
414
+ ]
415
+ end
416
+
417
+ def update_host_vm_group_option_types
418
+ [
419
+ {'fieldName' => 'name', 'fieldLabel' => 'Name', 'type' => 'text'},
420
+ ]
421
+ end
422
+
423
+ end
@@ -0,0 +1,118 @@
1
+ require 'morpheus/cli/mixins/print_helper'
2
+
3
+ # Mixin for Morpheus::Cli command classes
4
+ # Single source of truth for affinity group / host-vm group enums and formatting.
5
+ #
6
+ # Keep this in sync with morpheus-ui and the OpenAPI shared affinity-type schema.
7
+ # Defining these values here (rather than inline in each command) prevents the
8
+ # definitions from drifting apart between cluster-scoped, cloud-scoped and
9
+ # top-level flat-by-ID commands.
10
+ module Morpheus::Cli::AffinityHelper
11
+
12
+ def self.included(klass)
13
+ klass.send :include, Morpheus::Cli::PrintHelper
14
+ end
15
+
16
+ # All affinity types supported by the API.
17
+ # The _MUST variants are mandatory placement constraints and are what the
18
+ # violations endpoint reports on.
19
+ AFFINITY_TYPES = [
20
+ {'name' => 'Keep Together (Should)', 'value' => 'KEEP_TOGETHER'},
21
+ {'name' => 'Keep Separate (Should)', 'value' => 'KEEP_SEPARATE'},
22
+ {'name' => 'Keep Together (Must)', 'value' => 'KEEP_TOGETHER_MUST'},
23
+ {'name' => 'Keep Separate (Must)', 'value' => 'KEEP_SEPARATE_MUST'},
24
+ ].freeze
25
+
26
+ # Group types, per HostVmGroup.HostVmGroupType (morpheus-ui rc-9.1.0,
27
+ # morpheus-domains/grails-app/domain/com/morpheus/HostVmGroup.groovy).
28
+ # SITE_GROUP exists in the enum but is not valid on either side of a combined
29
+ # VM-to-Host rule -- AffinityGroupService.applyRuleShapeFields rejects it.
30
+ HOST_VM_GROUP_TYPES = [
31
+ {'name' => 'Site Group', 'value' => 'SITE_GROUP'},
32
+ {'name' => 'Host Group', 'value' => 'HOST_GROUP'},
33
+ {'name' => 'VM Group', 'value' => 'VM_GROUP'},
34
+ ].freeze
35
+
36
+ def affinity_type_select_options
37
+ AFFINITY_TYPES.map {|it| it.dup }
38
+ end
39
+
40
+ def host_vm_group_type_select_options
41
+ HOST_VM_GROUP_TYPES.map {|it| it.dup }
42
+ end
43
+
44
+ # affinityType is editable on update as well as create.
45
+ # AffinityGroupService.updateAffinityGroup applies params.affinityGroup.affinityType,
46
+ # so callers may switch between MUST and non-MUST behaviour during an edit.
47
+ def affinity_type_option_type(required: false, default: nil)
48
+ opt = {
49
+ 'fieldName' => 'affinityType',
50
+ 'fieldLabel' => 'Type',
51
+ 'type' => 'select',
52
+ 'selectOptions' => affinity_type_select_options,
53
+ 'description' => 'Affinity type. The _MUST variants are mandatory placement constraints.',
54
+ }
55
+ opt['required'] = true if required
56
+ opt['defaultValue'] = default if default
57
+ opt
58
+ end
59
+
60
+ def host_vm_group_type_option_type(required: false, default: nil)
61
+ opt = {
62
+ 'fieldName' => 'type',
63
+ 'fieldLabel' => 'Group Type',
64
+ 'type' => 'select',
65
+ 'selectOptions' => host_vm_group_type_select_options,
66
+ 'description' => 'Choose group type.',
67
+ }
68
+ opt['required'] = true if required
69
+ opt['defaultValue'] = default if default
70
+ opt
71
+ end
72
+
73
+ def format_affinity_type(affinity_type)
74
+ found = AFFINITY_TYPES.find {|it| it['value'] == affinity_type.to_s }
75
+ found ? found['name'].sub(' (Should)', '') : affinity_type.to_s
76
+ end
77
+
78
+ def format_host_vm_group_type(type)
79
+ found = HOST_VM_GROUP_TYPES.find {|it| it['value'] == type.to_s }
80
+ found ? found['name'] : type.to_s
81
+ end
82
+
83
+ # Groups carry poolId; keep only those in the chosen cluster.
84
+ def filter_by_pool(rows, pool_id)
85
+ return rows if pool_id.nil?
86
+ rows.select {|r| r['poolId'].nil? || r['poolId'].to_s == pool_id.to_s }
87
+ end
88
+
89
+ # Surface the server's own error text for affinity failures.
90
+ #
91
+ # The API returns {success:false, msg:"<human text>", errors:{<field>:"<i18n key>"}}
92
+ # -- there is no top-level errorCode. AffinityGroupService and
93
+ # VmwareComputeService.combinedRuleGroupSyncGuard already compose actionable
94
+ # messages (cross-cluster group refs, groups not yet synced to vSphere, rule
95
+ # shape violations), so print them verbatim rather than restating them here.
96
+ def handle_affinity_rest_exception(e, options)
97
+ if e.respond_to?(:response) && e.response
98
+ begin
99
+ body = JSON.parse(e.response.to_s)
100
+ if body.is_a?(Hash) && (body['msg'] || body['errors'])
101
+ print_red_alert body['msg'] if body['msg']
102
+ errors = body['errors']
103
+ if errors.is_a?(Hash) && !errors.empty?
104
+ # Same stream as print_red_alert, so msg and field detail stay together.
105
+ errors.each do |field, message|
106
+ $stderr.print "#{red} #{field}: #{message}#{reset}\n"
107
+ end
108
+ end
109
+ return
110
+ end
111
+ rescue TypeError, JSON::ParserError
112
+ # fall through to the generic handler
113
+ end
114
+ end
115
+ print_rest_exception(e, options)
116
+ end
117
+
118
+ end
@@ -21,6 +21,11 @@ module Morpheus::Cli::InfrastructureHelper
21
21
  @clouds_interface
22
22
  end
23
23
 
24
+ def clusters_interface
25
+ raise "#{self.class} has not defined @clusters_interface" if @clusters_interface.nil?
26
+ @clusters_interface
27
+ end
28
+
24
29
  def networks_interface
25
30
  # @api_client.networks
26
31
  raise "#{self.class} has not defined @networks_interface" if @networks_interface.nil?
@@ -414,6 +419,138 @@ module Morpheus::Cli::InfrastructureHelper
414
419
  end
415
420
  end
416
421
 
422
+ # Resolve a cloud-owned vSphere cluster: a ComputeZonePool with type='Cluster'
423
+ # that belongs to the given cloud. This is distinct from a Morpheus managed
424
+ # cluster (ComputeServerGroup, resolved via ClustersInterface / /api/clusters).
425
+ #
426
+ # MORPH-17279: --cloud CLOUD --cluster CLUSTER must resolve CLUSTER within
427
+ # CLOUD's own resource pools, not the top-level managed-cluster namespace.
428
+ # `cloud` is the already-resolved cloud Hash (from find_cloud_by_name_or_id).
429
+ def find_resource_pool_cluster_by_name_or_id(cloud, val)
430
+ if val.to_s =~ /\A\d{1,}\Z/
431
+ return find_resource_pool_cluster_by_id(cloud, val)
432
+ else
433
+ return find_resource_pool_cluster_by_name(cloud, val)
434
+ end
435
+ end
436
+
437
+ def find_resource_pool_cluster_by_id(cloud, id)
438
+ begin
439
+ # Scoped to the cloud so the server itself verifies pool ownership --
440
+ # ZoneResourcePoolsController#show 404s when the pool's zone does not
441
+ # match the requested cloud.
442
+ json_response = resource_pools_interface.get(cloud['id'], id.to_i)
443
+ rescue RestClient::Exception => e
444
+ if e.response && e.response.code == 404
445
+ print_red_alert "Cluster not found by id #{id} in cloud '#{cloud['name']}'"
446
+ return nil
447
+ else
448
+ raise e
449
+ end
450
+ end
451
+ resource_pool = json_response['resourcePool']
452
+ if resource_pool.nil?
453
+ print_red_alert "Cluster not found by id #{id} in cloud '#{cloud['name']}'"
454
+ return nil
455
+ end
456
+ if resource_pool['type'].to_s != 'Cluster'
457
+ print_red_alert "Resource pool #{id} in cloud '#{cloud['name']}' is a '#{resource_pool['type'] || 'unknown'}' resource pool, not a Cluster"
458
+ return nil
459
+ end
460
+ return resource_pool
461
+ end
462
+
463
+ def find_resource_pool_cluster_by_name(cloud, name)
464
+ json_response = resource_pools_interface.list(cloud['id'], {name: name.to_s, type: 'Cluster'})
465
+ # The cloud-scoped resource-pools endpoint returns a pool *tree*, backfilling
466
+ # ancestor folder pools so the hierarchy renders correctly. Ancestors do not
467
+ # satisfy our name/type filter, so re-filter here rather than trusting the
468
+ # raw result size for ambiguity detection.
469
+ resource_pools = (json_response['resourcePools'] || []).select do |it|
470
+ it['type'].to_s == 'Cluster' && (it['name'].to_s == name.to_s || it['displayName'].to_s == name.to_s)
471
+ end
472
+ if resource_pools.empty?
473
+ print_red_alert "Cluster not found by name '#{name}' in cloud '#{cloud['name']}'.\n" \
474
+ "To look up a Morpheus managed cluster instead, use --cluster '#{name}' without --cloud."
475
+ return nil
476
+ elsif resource_pools.size > 1
477
+ print_red_alert "#{resource_pools.size} clusters found by name '#{name}' in cloud '#{cloud['name']}'. Use a numeric cluster id instead."
478
+ rows = resource_pools.collect do |it|
479
+ {id: it['id'], name: it['name']}
480
+ end
481
+ puts as_pretty_table(rows, [:id, :name], {color:red})
482
+ return nil
483
+ else
484
+ return resource_pools[0]
485
+ end
486
+ end
487
+
488
+ def find_managed_cluster_by_name_or_id(val, report_not_found=true)
489
+ cluster = nil
490
+ if val.to_s =~ /\A\d{1,}\Z/
491
+ begin
492
+ cluster = clusters_interface.get(val.to_i)['cluster']
493
+ rescue RestClient::Exception => e
494
+ raise e unless e.response && e.response.code == 404
495
+ end
496
+ else
497
+ clusters = clusters_interface.list({name: val})['clusters'] || []
498
+ if clusters.size > 1
499
+ print_red_alert "#{clusters.size} managed clusters found by name '#{val}'. Use a numeric cluster id instead."
500
+ rows = clusters.collect {|it| {id: it['id'], name: it['name']} }
501
+ puts as_pretty_table(rows, [:id, :name], {color:red})
502
+ return nil
503
+ end
504
+ cluster = clusters[0]
505
+ end
506
+ if cluster.nil? && report_not_found
507
+ print_red_alert "Managed cluster not found by name or id '#{val}'.\n" \
508
+ "If '#{val}' is a vSphere cluster owned by a cloud, use --cloud CLOUD --cluster #{val} instead."
509
+ end
510
+ cluster
511
+ end
512
+
513
+ def find_cluster_list_scope_by_name_or_id(val)
514
+ if val.to_s =~ /\A\d{1,}\Z/
515
+ cluster = find_managed_cluster_by_name_or_id(val)
516
+ return cluster ? {type: :managed_cluster, cluster: cluster} : nil
517
+ end
518
+
519
+ managed_clusters = clusters_interface.list({name: val})['clusters'] || []
520
+ if managed_clusters.size > 1
521
+ print_red_alert "#{managed_clusters.size} managed clusters found by name '#{val}'. Use a numeric cluster id instead."
522
+ rows = managed_clusters.collect {|it| {id: it['id'], name: it['name']} }
523
+ puts as_pretty_table(rows, [:id, :name], {color:red})
524
+ return nil
525
+ elsif managed_clusters.size == 1
526
+ return {type: :managed_cluster, cluster: managed_clusters[0]}
527
+ end
528
+
529
+ json_response = resource_pools_interface.list_without_cloud({name: val.to_s, type: 'Cluster', max: 1000})
530
+ resource_pools = (json_response['resourcePools'] || []).select do |it|
531
+ it['type'].to_s == 'Cluster' && (it['name'].to_s == val.to_s || it['displayName'].to_s == val.to_s)
532
+ end
533
+ if resource_pools.empty?
534
+ print_red_alert "No managed or vSphere cluster found by name '#{val}'."
535
+ return nil
536
+ elsif resource_pools.size > 1
537
+ print_red_alert "#{resource_pools.size} vSphere clusters found by name '#{val}'. Use --cloud CLOUD --cluster #{val} to disambiguate."
538
+ rows = resource_pools.collect do |it|
539
+ {id: it['id'], name: it['name'], cloud: it['zone'] && it['zone']['name']}
540
+ end
541
+ puts as_pretty_table(rows, [:id, :name, :cloud], {color:red})
542
+ return nil
543
+ end
544
+
545
+ resource_pool = resource_pools[0]
546
+ cloud = resource_pool['zone']
547
+ if cloud.nil? || cloud['id'].nil?
548
+ print_red_alert "vSphere cluster '#{val}' does not include its owning cloud."
549
+ return nil
550
+ end
551
+ {type: :cloud_pool, cloud: cloud, pool: resource_pool}
552
+ end
553
+
417
554
  def find_resource_pool_group_by_name_or_id(val)
418
555
  if val.to_s =~ /\A\d{1,}\Z/
419
556
  return find_resource_pool_group_by_id(val)