morpheus-cli 9.0.1 → 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 +17 -0
- data/lib/morpheus/api/clouds_interface.rb +42 -0
- data/lib/morpheus/api/clusters_interface.rb +55 -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/api/servers_interface.rb +8 -0
- data/lib/morpheus/api/virtual_switches_interface.rb +9 -0
- data/lib/morpheus/cli/commands/affinity_groups_command.rb +554 -0
- data/lib/morpheus/cli/commands/backups_command.rb +11 -0
- data/lib/morpheus/cli/commands/clouds.rb +421 -29
- data/lib/morpheus/cli/commands/clusters.rb +507 -20
- 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/commands/hosts.rb +72 -1
- data/lib/morpheus/cli/commands/instances.rb +12 -1
- data/lib/morpheus/cli/commands/networks_command.rb +35 -3
- data/lib/morpheus/cli/commands/virtual_switches_command.rb +336 -0
- data/lib/morpheus/cli/mixins/affinity_helper.rb +118 -0
- data/lib/morpheus/cli/mixins/infrastructure_helper.rb +143 -4
- 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 +39 -2
|
@@ -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?
|
|
@@ -166,11 +171,11 @@ module Morpheus::Cli::InfrastructureHelper
|
|
|
166
171
|
|
|
167
172
|
# Networks
|
|
168
173
|
|
|
169
|
-
def find_network_by_name_or_id(val)
|
|
174
|
+
def find_network_by_name_or_id(val, include_tenants=false)
|
|
170
175
|
if val.to_s =~ /\A\d{1,}\Z/
|
|
171
176
|
return find_network_by_id(val)
|
|
172
177
|
else
|
|
173
|
-
return find_network_by_name(val)
|
|
178
|
+
return find_network_by_name(val, include_tenants)
|
|
174
179
|
end
|
|
175
180
|
end
|
|
176
181
|
|
|
@@ -188,8 +193,10 @@ module Morpheus::Cli::InfrastructureHelper
|
|
|
188
193
|
end
|
|
189
194
|
end
|
|
190
195
|
|
|
191
|
-
def find_network_by_name(name)
|
|
192
|
-
|
|
196
|
+
def find_network_by_name(name, include_tenants=false)
|
|
197
|
+
params = {name: name.to_s}
|
|
198
|
+
params['includeTenants'] = true if include_tenants
|
|
199
|
+
json_response = networks_interface.list(params)
|
|
193
200
|
networks = json_response['networks']
|
|
194
201
|
if networks.empty?
|
|
195
202
|
print_red_alert "Network not found by name #{name}"
|
|
@@ -412,6 +419,138 @@ module Morpheus::Cli::InfrastructureHelper
|
|
|
412
419
|
end
|
|
413
420
|
end
|
|
414
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
|
+
|
|
415
554
|
def find_resource_pool_group_by_name_or_id(val)
|
|
416
555
|
if val.to_s =~ /\A\d{1,}\Z/
|
|
417
556
|
return find_resource_pool_group_by_id(val)
|
|
@@ -1501,13 +1501,48 @@ module Morpheus
|
|
|
1501
1501
|
end
|
|
1502
1502
|
|
|
1503
1503
|
def self.sort_option_types(option_types)
|
|
1504
|
-
if option_types.find {|it| it['fieldGroup'] || it['displayOrder'] }
|
|
1504
|
+
sorted = if option_types.find {|it| it['fieldGroup'] || it['displayOrder'] }
|
|
1505
1505
|
option_types.select {|it| (it['fieldGroup'] || 'default').casecmp?('default')}.sort {|a,b| a['displayOrder'].to_i <=> b['displayOrder'].to_i} +
|
|
1506
1506
|
option_types.reject {|it| ['default', 'advanced'].include?((it['fieldGroup'] || 'default').downcase)}.sort{|a,b| a['displayOrder'] <=> b['displayOrder']}.group_by{|it| it['fieldGroup']}.values.collect { |it| it.sort{|a,b| a['displayOrder'].to_i <=> b['displayOrder'].to_i}}.flatten +
|
|
1507
1507
|
option_types.select {|it| 'advanced'.casecmp?(it['fieldGroup'])}.sort {|a,b| a['displayOrder'].to_i <=> b['displayOrder'].to_i}
|
|
1508
1508
|
else
|
|
1509
1509
|
option_types
|
|
1510
1510
|
end
|
|
1511
|
+
reorder_option_type_dependencies(sorted)
|
|
1512
|
+
end
|
|
1513
|
+
|
|
1514
|
+
# displayOrder alone can put a field ahead of another field it depends on
|
|
1515
|
+
# via dependsOnCode/visibleOnCode (eg. a hidden, optionSource-populated field
|
|
1516
|
+
# with a high displayOrder that a lower displayOrder field is gated on). Move
|
|
1517
|
+
# any depended-upon option type ahead of the option type(s) that depend on it,
|
|
1518
|
+
# preserving displayOrder order otherwise.
|
|
1519
|
+
def self.reorder_option_type_dependencies(option_types)
|
|
1520
|
+
field_key_for = lambda {|it| it['fieldContext'].to_s.empty? ? it['fieldName'].to_s : "#{it['fieldContext']}.#{it['fieldName']}" }
|
|
1521
|
+
depends_on_codes_for = lambda {|option_type|
|
|
1522
|
+
depends_value = option_type['visibleOnCode'].to_s.empty? ? option_type['dependsOnCode'] : option_type['visibleOnCode']
|
|
1523
|
+
next [] if depends_value.to_s.empty? || depends_value == 'networkInterfaces'
|
|
1524
|
+
depends_value = depends_value[(depends_value.index('::') + 2)..-1] if depends_value.include?('::')
|
|
1525
|
+
depends_value.to_s.sub(',', ' ').split(' ').collect {|value| value.split(':')[0] }
|
|
1526
|
+
}
|
|
1527
|
+
result = option_types.dup
|
|
1528
|
+
passes = 0
|
|
1529
|
+
changed = true
|
|
1530
|
+
while changed && passes < result.size
|
|
1531
|
+
changed = false
|
|
1532
|
+
passes += 1
|
|
1533
|
+
result.each_with_index do |option_type, i|
|
|
1534
|
+
depends_on_codes_for.call(option_type).each do |depends_on_code|
|
|
1535
|
+
dep_index = result.index {|it| it['code'] == depends_on_code || field_key_for.call(it) == depends_on_code }
|
|
1536
|
+
if dep_index && dep_index > i
|
|
1537
|
+
result.insert(i, result.delete_at(dep_index))
|
|
1538
|
+
changed = true
|
|
1539
|
+
break
|
|
1540
|
+
end
|
|
1541
|
+
end
|
|
1542
|
+
break if changed
|
|
1543
|
+
end
|
|
1544
|
+
end
|
|
1545
|
+
result
|
|
1511
1546
|
end
|
|
1512
1547
|
|
|
1513
1548
|
def self.display_select_options(opt, select_options = [], paging = nil)
|
data/lib/morpheus/cli/version.rb
CHANGED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
require 'test/unit'
|
|
2
|
+
$LOAD_PATH.unshift(File.expand_path('../../lib', __dir__))
|
|
3
|
+
require 'morpheus/api/affinity_groups_interface'
|
|
4
|
+
|
|
5
|
+
class AffinityGroupsInterfaceTest < Test::Unit::TestCase
|
|
6
|
+
|
|
7
|
+
def setup
|
|
8
|
+
@affinity_groups_interface = Morpheus::AffinityGroupsInterface.new(access_token: 'token', url: 'https://example.test', verify_ssl: false)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def test_get_builds_bearer_authorization_header
|
|
12
|
+
request = @affinity_groups_interface.dry.get(123, details: true)
|
|
13
|
+
assert_equal :get, request[:method]
|
|
14
|
+
assert_equal 'https://example.test/api/affinity-groups/123', request[:url]
|
|
15
|
+
assert_equal({details: true}, request[:headers][:params])
|
|
16
|
+
assert_equal 'Bearer token', request[:headers][:authorization]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def test_update_builds_bearer_authorization_header
|
|
20
|
+
request = @affinity_groups_interface.dry.update(123, {name: 'rule-a'})
|
|
21
|
+
assert_equal :put, request[:method]
|
|
22
|
+
assert_equal 'https://example.test/api/affinity-groups/123', request[:url]
|
|
23
|
+
assert_equal({name: 'rule-a'}.to_json, request[:payload])
|
|
24
|
+
assert_equal 'Bearer token', request[:headers][:authorization]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def test_destroy_builds_bearer_authorization_header
|
|
28
|
+
request = @affinity_groups_interface.dry.destroy(123, force: true)
|
|
29
|
+
assert_equal :delete, request[:method]
|
|
30
|
+
assert_equal 'https://example.test/api/affinity-groups/123', request[:url]
|
|
31
|
+
assert_equal({force: true}, request[:headers][:params])
|
|
32
|
+
assert_equal 'Bearer token', request[:headers][:authorization]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def test_violations_builds_bearer_authorization_header
|
|
36
|
+
request = @affinity_groups_interface.dry.violations(scope: 'cloud')
|
|
37
|
+
assert_equal :get, request[:method]
|
|
38
|
+
assert_equal 'https://example.test/api/affinity-groups/violations', request[:url]
|
|
39
|
+
assert_equal({scope: 'cloud'}, request[:headers][:params])
|
|
40
|
+
assert_equal 'Bearer token', request[:headers][:authorization]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
require 'test/unit'
|
|
2
|
+
$LOAD_PATH.unshift(File.expand_path('../../lib', __dir__))
|
|
3
|
+
require 'morpheus/api/clouds_interface'
|
|
4
|
+
|
|
5
|
+
class CloudsInterfaceTest < Test::Unit::TestCase
|
|
6
|
+
|
|
7
|
+
def setup
|
|
8
|
+
@clouds_interface = Morpheus::CloudsInterface.new(access_token: 'token', url: 'https://example.test', verify_ssl: false)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def test_affinity_group_form_options_builds_bearer_authorization_header
|
|
12
|
+
request = @clouds_interface.dry.affinity_group_form_options(59, phase: 'create')
|
|
13
|
+
assert_equal :get, request[:method]
|
|
14
|
+
assert_equal 'https://example.test/api/zones/59/affinity-groups/form-options', request[:url]
|
|
15
|
+
assert_equal({phase: 'create'}, request[:headers][:params])
|
|
16
|
+
assert_equal 'Bearer token', request[:headers][:authorization]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def test_list_host_vm_groups_builds_bearer_authorization_header
|
|
20
|
+
request = @clouds_interface.dry.list_host_vm_groups(59, phrase: 'group')
|
|
21
|
+
assert_equal :get, request[:method]
|
|
22
|
+
assert_equal 'https://example.test/api/zones/59/host-vm-groups', request[:url]
|
|
23
|
+
assert_equal({phrase: 'group'}, request[:headers][:params])
|
|
24
|
+
assert_equal 'Bearer token', request[:headers][:authorization]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def test_get_host_vm_group_builds_bearer_authorization_header
|
|
28
|
+
request = @clouds_interface.dry.get_host_vm_group(59, 415, details: true)
|
|
29
|
+
assert_equal :get, request[:method]
|
|
30
|
+
assert_equal 'https://example.test/api/zones/59/host-vm-groups/415', request[:url]
|
|
31
|
+
assert_equal({details: true}, request[:headers][:params])
|
|
32
|
+
assert_equal 'Bearer token', request[:headers][:authorization]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def test_create_host_vm_group_builds_bearer_authorization_header
|
|
36
|
+
request = @clouds_interface.dry.create_host_vm_group(59, {name: 'group-a'})
|
|
37
|
+
assert_equal :post, request[:method]
|
|
38
|
+
assert_equal 'https://example.test/api/zones/59/host-vm-groups', request[:url]
|
|
39
|
+
assert_equal({name: 'group-a'}.to_json, request[:payload])
|
|
40
|
+
assert_equal 'Bearer token', request[:headers][:authorization]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def test_update_host_vm_group_builds_bearer_authorization_header
|
|
44
|
+
request = @clouds_interface.dry.update_host_vm_group(59, 415, {name: 'group-b'})
|
|
45
|
+
assert_equal :put, request[:method]
|
|
46
|
+
assert_equal 'https://example.test/api/zones/59/host-vm-groups/415', request[:url]
|
|
47
|
+
assert_equal({name: 'group-b'}.to_json, request[:payload])
|
|
48
|
+
assert_equal 'Bearer token', request[:headers][:authorization]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def test_destroy_host_vm_group_builds_bearer_authorization_header
|
|
52
|
+
request = @clouds_interface.dry.destroy_host_vm_group(59, 415, force: true)
|
|
53
|
+
assert_equal :delete, request[:method]
|
|
54
|
+
assert_equal 'https://example.test/api/zones/59/host-vm-groups/415', request[:url]
|
|
55
|
+
assert_equal({force: true}, request[:headers][:params])
|
|
56
|
+
assert_equal 'Bearer token', request[:headers][:authorization]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def test_host_vm_group_form_options_builds_bearer_authorization_header
|
|
60
|
+
request = @clouds_interface.dry.host_vm_group_form_options(59, scope: 'qa')
|
|
61
|
+
assert_equal :get, request[:method]
|
|
62
|
+
assert_equal 'https://example.test/api/zones/59/host-vm-groups/form-options', request[:url]
|
|
63
|
+
assert_equal({scope: 'qa'}, request[:headers][:params])
|
|
64
|
+
assert_equal 'Bearer token', request[:headers][:authorization]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require 'test/unit'
|
|
2
|
+
$LOAD_PATH.unshift(File.expand_path('../../lib', __dir__))
|
|
3
|
+
require 'morpheus/api/host_vm_groups_interface'
|
|
4
|
+
|
|
5
|
+
class HostVmGroupsInterfaceTest < Test::Unit::TestCase
|
|
6
|
+
|
|
7
|
+
def setup
|
|
8
|
+
@host_vm_groups_interface = Morpheus::HostVmGroupsInterface.new(access_token: 'token', url: 'https://example.test', verify_ssl: false)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def test_get_builds_bearer_authorization_header
|
|
12
|
+
request = @host_vm_groups_interface.dry.get(415, details: true)
|
|
13
|
+
assert_equal :get, request[:method]
|
|
14
|
+
assert_equal 'https://example.test/api/host-vm-groups/415', request[:url]
|
|
15
|
+
assert_equal({details: true}, request[:headers][:params])
|
|
16
|
+
assert_equal 'Bearer token', request[:headers][:authorization]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def test_update_builds_bearer_authorization_header
|
|
20
|
+
request = @host_vm_groups_interface.dry.update(415, {name: 'group-a'})
|
|
21
|
+
assert_equal :put, request[:method]
|
|
22
|
+
assert_equal 'https://example.test/api/host-vm-groups/415', request[:url]
|
|
23
|
+
assert_equal({name: 'group-a'}.to_json, request[:payload])
|
|
24
|
+
assert_equal 'Bearer token', request[:headers][:authorization]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def test_destroy_builds_bearer_authorization_header
|
|
28
|
+
request = @host_vm_groups_interface.dry.destroy(415, force: true)
|
|
29
|
+
assert_equal :delete, request[:method]
|
|
30
|
+
assert_equal 'https://example.test/api/host-vm-groups/415', request[:url]
|
|
31
|
+
assert_equal({force: true}, request[:headers][:params])
|
|
32
|
+
assert_equal 'Bearer token', request[:headers][:authorization]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
end
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
require 'test/unit'
|
|
2
|
+
require 'stringio'
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'morpheus'
|
|
5
|
+
|
|
6
|
+
# Command-level offline exercise of the MORPH-17279 --cloud + --cluster
|
|
7
|
+
# (vSphere cluster) scope for AffinityGroupsCommand and HostVmGroupsCommand
|
|
8
|
+
# list/add. Runs the real command methods end-to-end (option parsing, scope
|
|
9
|
+
# resolution, --dry-run request construction) against fake interfaces, so no
|
|
10
|
+
# live appliance or credentials are required.
|
|
11
|
+
#
|
|
12
|
+
# This complements infrastructure_helper_cluster_resolver_test.rb (which
|
|
13
|
+
# unit-tests the resolver in isolation) by proving the resolved pool actually
|
|
14
|
+
# flows into list (poolId query param) and add (pool.id in the create
|
|
15
|
+
# payload), exactly as it would against a real appliance.
|
|
16
|
+
class AffinityGroupsCloudClusterScopeTest < Test::Unit::TestCase
|
|
17
|
+
|
|
18
|
+
# Fake for Morpheus::CloudsInterface -- records every call, answers cloud
|
|
19
|
+
# name/id lookups, and answers .dry.* calls with a print_dry_run-compatible
|
|
20
|
+
# request hash (mirrors how Morpheus::APIClient#dry / #execute behave).
|
|
21
|
+
class FakeCloudsInterface
|
|
22
|
+
attr_reader :calls
|
|
23
|
+
|
|
24
|
+
def initialize(cloud:)
|
|
25
|
+
@cloud = cloud
|
|
26
|
+
@calls = []
|
|
27
|
+
@dry = false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def setopts(options); self; end
|
|
31
|
+
def dry; @dry = true; self; end
|
|
32
|
+
|
|
33
|
+
def get(id, params = {})
|
|
34
|
+
@calls << [:get, id, params]
|
|
35
|
+
{'zone' => @cloud}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def list(params = {})
|
|
39
|
+
@calls << [:list, params]
|
|
40
|
+
{'zones' => [@cloud]}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def list_affinity_groups(id, params = {})
|
|
44
|
+
@calls << [:list_affinity_groups, id, params]
|
|
45
|
+
respond({'affinityGroups' => []}) { {method: :get, url: "/api/zones/#{id}/affinity-groups", params: params} }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def create_affinity_group(id, payload)
|
|
49
|
+
@calls << [:create_affinity_group, id, payload]
|
|
50
|
+
respond({'affinityGroup' => {'id' => 1}}) { {method: :post, url: "/api/zones/#{id}/affinity-groups", payload: payload.to_json} }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def affinity_group_form_options(id, params = {})
|
|
54
|
+
@calls << [:affinity_group_form_options, id, params]
|
|
55
|
+
{'supportsCombinedRules' => true, 'availableVmGroups' => [], 'availableHostGroups' => []}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def list_host_vm_groups(id, params = {})
|
|
59
|
+
@calls << [:list_host_vm_groups, id, params]
|
|
60
|
+
respond({'hostVmGroups' => []}) { {method: :get, url: "/api/zones/#{id}/host-vm-groups", params: params} }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def create_host_vm_group(id, payload)
|
|
64
|
+
@calls << [:create_host_vm_group, id, payload]
|
|
65
|
+
respond({'hostVmGroup' => {'id' => 1}}) { {method: :post, url: "/api/zones/#{id}/host-vm-groups", payload: payload.to_json} }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def host_vm_group_form_options(id, params = {})
|
|
69
|
+
@calls << [:host_vm_group_form_options, id, params]
|
|
70
|
+
{'availableServers' => []}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def respond(real_result)
|
|
76
|
+
@dry ? yield : real_result
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Fake for Morpheus::CloudResourcePoolsInterface -- used by
|
|
81
|
+
# find_resource_pool_cluster_by_name_or_id.
|
|
82
|
+
class FakeResourcePoolsInterface
|
|
83
|
+
attr_reader :calls
|
|
84
|
+
|
|
85
|
+
def initialize(pool:)
|
|
86
|
+
@pool = pool
|
|
87
|
+
@calls = []
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def get(cloud_id, id, params = {})
|
|
91
|
+
@calls << [:get, cloud_id, id, params]
|
|
92
|
+
{'resourcePool' => @pool}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def list(cloud_id, params = {})
|
|
96
|
+
@calls << [:list, cloud_id, params]
|
|
97
|
+
{'resourcePools' => [@pool]}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def list_without_cloud(params = {})
|
|
101
|
+
@calls << [:list_without_cloud, params]
|
|
102
|
+
{'resourcePools' => [@pool]}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
class FakeClustersInterface
|
|
107
|
+
attr_reader :calls
|
|
108
|
+
|
|
109
|
+
def initialize(clusters: [])
|
|
110
|
+
@clusters = clusters
|
|
111
|
+
@calls = []
|
|
112
|
+
@dry = false
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def setopts(options); self; end
|
|
116
|
+
def dry; @dry = true; self; end
|
|
117
|
+
|
|
118
|
+
def get(id, params = {})
|
|
119
|
+
@calls << [:get, id, params]
|
|
120
|
+
{'cluster' => @clusters.find {|cluster| cluster['id'].to_i == id.to_i}}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def list(params = {})
|
|
124
|
+
@calls << [:list, params]
|
|
125
|
+
{'clusters' => @clusters.select {|cluster| cluster['name'] == params[:name]}}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def list_affinity_groups(id, params = {})
|
|
129
|
+
@calls << [:list_affinity_groups, id, params]
|
|
130
|
+
respond({'affinityGroups' => []}) { {method: :get, url: "/api/clusters/#{id}/affinity-groups", params: params} }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def list_host_vm_groups(id, params = {})
|
|
134
|
+
@calls << [:list_host_vm_groups, id, params]
|
|
135
|
+
respond({'hostVmGroups' => []}) { {method: :get, url: "/api/clusters/#{id}/host-vm-groups", params: params} }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def respond(real_result)
|
|
141
|
+
@dry ? yield : real_result
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def cloud
|
|
146
|
+
{'id' => 59, 'name' => 'vCenter Cloud'}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def pool
|
|
150
|
+
{'id' => 501, 'name' => 'QA', 'type' => 'Cluster', 'zone' => {'id' => 59, 'name' => 'vCenter Cloud'}}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def build_affinity_command(managed_clusters: [])
|
|
154
|
+
cmd = Morpheus::Cli::AffinityGroupsCommand.new
|
|
155
|
+
fake_clouds = FakeCloudsInterface.new(cloud: cloud)
|
|
156
|
+
fake_pools = FakeResourcePoolsInterface.new(pool: pool)
|
|
157
|
+
fake_clusters = FakeClustersInterface.new(clusters: managed_clusters)
|
|
158
|
+
cmd.define_singleton_method(:connect) do |opts|
|
|
159
|
+
instance_variable_set(:@affinity_groups_interface, nil)
|
|
160
|
+
instance_variable_set(:@clouds_interface, fake_clouds)
|
|
161
|
+
instance_variable_set(:@clusters_interface, fake_clusters)
|
|
162
|
+
instance_variable_set(:@cloud_resource_pools_interface, fake_pools)
|
|
163
|
+
end
|
|
164
|
+
[cmd, fake_clouds, fake_pools, fake_clusters]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def build_host_vm_command(managed_clusters: [])
|
|
168
|
+
cmd = Morpheus::Cli::HostVmGroupsCommand.new
|
|
169
|
+
fake_clouds = FakeCloudsInterface.new(cloud: cloud)
|
|
170
|
+
fake_pools = FakeResourcePoolsInterface.new(pool: pool)
|
|
171
|
+
fake_clusters = FakeClustersInterface.new(clusters: managed_clusters)
|
|
172
|
+
cmd.define_singleton_method(:connect) do |opts|
|
|
173
|
+
instance_variable_set(:@host_vm_groups_interface, nil)
|
|
174
|
+
instance_variable_set(:@clouds_interface, fake_clouds)
|
|
175
|
+
instance_variable_set(:@clusters_interface, fake_clusters)
|
|
176
|
+
instance_variable_set(:@cloud_resource_pools_interface, fake_pools)
|
|
177
|
+
end
|
|
178
|
+
[cmd, fake_clouds, fake_pools, fake_clusters]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def capture_stdout
|
|
182
|
+
terminal = Morpheus::Terminal.instance
|
|
183
|
+
original = terminal.stdout
|
|
184
|
+
buffer = StringIO.new
|
|
185
|
+
terminal.set_stdout(buffer)
|
|
186
|
+
yield
|
|
187
|
+
buffer.string
|
|
188
|
+
ensure
|
|
189
|
+
terminal.set_stdout(original)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
## AffinityGroupsCommand
|
|
193
|
+
|
|
194
|
+
def test_affinity_groups_list_cloud_cluster_combo_sends_pool_id
|
|
195
|
+
cmd, _fake_clouds, fake_pools, _fake_clusters = build_affinity_command
|
|
196
|
+
output = capture_stdout { cmd.list(['--cloud', 'vCenter Cloud', '--cluster', 'QA', '--dry-run']) }
|
|
197
|
+
assert_match(/poolId=501/, output, "dry-run request must carry the resolved poolId, got: #{output}")
|
|
198
|
+
assert_match(%r{/api/zones/59/affinity-groups}, output)
|
|
199
|
+
# Name resolution stayed scoped to the cloud and to type=Cluster.
|
|
200
|
+
assert_equal [[:list, 59, {name: 'QA', type: 'Cluster'}]], fake_pools.calls
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def test_affinity_groups_add_cloud_cluster_combo_sets_pool_id_payload
|
|
204
|
+
cmd, fake_clouds, _fake_pools, _fake_clusters = build_affinity_command
|
|
205
|
+
payload_json = '{"affinityGroup":{"name":"cli-test","affinityType":"KEEP_TOGETHER","servers":[{"id":1}]}}'
|
|
206
|
+
output = capture_stdout do
|
|
207
|
+
cmd.add(['--cloud', 'vCenter Cloud', '--cluster', 'QA', '--payload-json', payload_json, '--dry-run'])
|
|
208
|
+
end
|
|
209
|
+
assert_match(%r{/api/zones/59/affinity-groups}, output, "must POST to the zone endpoint, not a cluster endpoint")
|
|
210
|
+
create_call = fake_clouds.calls.find {|c| c[0] == :create_affinity_group }
|
|
211
|
+
assert_not_nil create_call, "create_affinity_group must be called on the clouds interface"
|
|
212
|
+
payload = create_call[2].is_a?(String) ? JSON.parse(create_call[2]) : create_call[2]
|
|
213
|
+
assert_equal({'id' => 501}, payload['affinityGroup']['pool'])
|
|
214
|
+
assert_nil payload['affinityGroup']['cloudId']
|
|
215
|
+
assert_nil payload['affinityGroup']['clusterId']
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def test_affinity_groups_list_cluster_name_falls_back_to_unique_vsphere_cluster
|
|
219
|
+
cmd, _fake_clouds, fake_pools, fake_clusters = build_affinity_command
|
|
220
|
+
output = capture_stdout { cmd.list(['--cluster', 'QA', '--dry-run']) }
|
|
221
|
+
assert_match(%r{/api/zones/59/affinity-groups}, output)
|
|
222
|
+
assert_match(/poolId=501/, output)
|
|
223
|
+
assert_equal [[:list, {name: 'QA'}]], fake_clusters.calls
|
|
224
|
+
assert_equal [[:list_without_cloud, {name: 'QA', type: 'Cluster', max: 1000}]], fake_pools.calls
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def test_affinity_groups_list_cluster_name_preserves_managed_cluster_precedence
|
|
228
|
+
managed_cluster = {'id' => 8, 'name' => 'QA'}
|
|
229
|
+
cmd, _fake_clouds, fake_pools, fake_clusters = build_affinity_command(managed_clusters: [managed_cluster])
|
|
230
|
+
output = capture_stdout { cmd.list(['--cluster', 'QA', '--dry-run']) }
|
|
231
|
+
assert_match(%r{/api/clusters/8/affinity-groups}, output)
|
|
232
|
+
assert_equal [[:list, {name: 'QA'}], [:list_affinity_groups, 8, {}]], fake_clusters.calls
|
|
233
|
+
assert_empty fake_pools.calls
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
## HostVmGroupsCommand
|
|
237
|
+
|
|
238
|
+
def test_host_vm_groups_list_cloud_cluster_combo_sends_pool_id
|
|
239
|
+
cmd, _fake_clouds, fake_pools, _fake_clusters = build_host_vm_command
|
|
240
|
+
output = capture_stdout { cmd.list(['--cloud', 'vCenter Cloud', '--cluster', 'QA', '--dry-run']) }
|
|
241
|
+
assert_match(/poolId=501/, output, "dry-run request must carry the resolved poolId, got: #{output}")
|
|
242
|
+
assert_match(%r{/api/zones/59/host-vm-groups}, output)
|
|
243
|
+
assert_equal [[:list, 59, {name: 'QA', type: 'Cluster'}]], fake_pools.calls
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def test_host_vm_groups_add_cloud_cluster_combo_sets_pool_id_payload
|
|
247
|
+
cmd, fake_clouds, _fake_pools, _fake_clusters = build_host_vm_command
|
|
248
|
+
payload_json = '{"hostVmGroup":{"name":"cli-test","type":"HOST_GROUP","servers":[{"id":1}]}}'
|
|
249
|
+
output = capture_stdout do
|
|
250
|
+
cmd.add(['--cloud', 'vCenter Cloud', '--cluster', 'QA', '--payload-json', payload_json, '--dry-run'])
|
|
251
|
+
end
|
|
252
|
+
assert_match(%r{/api/zones/59/host-vm-groups}, output, "must POST to the zone endpoint, not a cluster endpoint")
|
|
253
|
+
create_call = fake_clouds.calls.find {|c| c[0] == :create_host_vm_group }
|
|
254
|
+
assert_not_nil create_call, "create_host_vm_group must be called on the clouds interface"
|
|
255
|
+
payload = create_call[2].is_a?(String) ? JSON.parse(create_call[2]) : create_call[2]
|
|
256
|
+
assert_equal({'id' => 501}, payload['hostVmGroup']['pool'])
|
|
257
|
+
assert_nil payload['hostVmGroup']['cloudId']
|
|
258
|
+
assert_nil payload['hostVmGroup']['clusterId']
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def test_host_vm_groups_list_cluster_name_falls_back_to_unique_vsphere_cluster
|
|
262
|
+
cmd, _fake_clouds, fake_pools, fake_clusters = build_host_vm_command
|
|
263
|
+
output = capture_stdout { cmd.list(['--cluster', 'QA', '--dry-run']) }
|
|
264
|
+
assert_match(%r{/api/zones/59/host-vm-groups}, output)
|
|
265
|
+
assert_match(/poolId=501/, output)
|
|
266
|
+
assert_equal [[:list, {name: 'QA'}]], fake_clusters.calls
|
|
267
|
+
assert_equal [[:list_without_cloud, {name: 'QA', type: 'Cluster', max: 1000}]], fake_pools.calls
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
end
|