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.
- checksums.yaml +4 -4
- data/Dockerfile +1 -1
- data/lib/morpheus/api/affinity_groups_interface.rb +46 -0
- data/lib/morpheus/api/api_client.rb +13 -0
- data/lib/morpheus/api/clouds_interface.rb +42 -0
- data/lib/morpheus/api/clusters_interface.rb +7 -1
- data/lib/morpheus/api/distributed_workers_interface.rb +41 -0
- data/lib/morpheus/api/host_vm_groups_interface.rb +37 -0
- data/lib/morpheus/cli/commands/affinity_groups_command.rb +554 -0
- data/lib/morpheus/cli/commands/backups_command.rb +1 -1
- data/lib/morpheus/cli/commands/clouds.rb +421 -29
- data/lib/morpheus/cli/commands/clusters.rb +37 -39
- data/lib/morpheus/cli/commands/distributed_workers_command.rb +282 -0
- data/lib/morpheus/cli/commands/host_vm_groups_command.rb +423 -0
- data/lib/morpheus/cli/mixins/affinity_helper.rb +118 -0
- data/lib/morpheus/cli/mixins/infrastructure_helper.rb +137 -0
- data/lib/morpheus/cli/option_types.rb +36 -1
- data/lib/morpheus/cli/version.rb +1 -1
- data/test/api/affinity_groups_interface_test.rb +43 -0
- data/test/api/clouds_interface_test.rb +67 -0
- data/test/api/host_vm_groups_interface_test.rb +35 -0
- data/test/cli/affinity_groups_cloud_cluster_scope_test.rb +270 -0
- data/test/cli/affinity_groups_cluster_scope_test.rb +171 -0
- data/test/cli/affinity_groups_test.rb +57 -0
- data/test/cli/affinity_groups_violations_scope_test.rb +141 -0
- data/test/cli/affinity_helper_test.rb +367 -0
- data/test/cli/backups_test.rb +212 -0
- data/test/cli/clusters_uuid_test.rb +131 -0
- data/test/cli/distributed_workers_test.rb +68 -0
- data/test/cli/host_vm_groups_cluster_scope_test.rb +187 -0
- data/test/cli/host_vm_groups_test.rb +63 -0
- data/test/cli/infrastructure_helper_cluster_resolver_test.rb +250 -0
- 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)
|