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.
Files changed (39) 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 +17 -0
  5. data/lib/morpheus/api/clouds_interface.rb +42 -0
  6. data/lib/morpheus/api/clusters_interface.rb +55 -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/api/servers_interface.rb +8 -0
  10. data/lib/morpheus/api/virtual_switches_interface.rb +9 -0
  11. data/lib/morpheus/cli/commands/affinity_groups_command.rb +554 -0
  12. data/lib/morpheus/cli/commands/backups_command.rb +11 -0
  13. data/lib/morpheus/cli/commands/clouds.rb +421 -29
  14. data/lib/morpheus/cli/commands/clusters.rb +507 -20
  15. data/lib/morpheus/cli/commands/distributed_workers_command.rb +282 -0
  16. data/lib/morpheus/cli/commands/host_vm_groups_command.rb +423 -0
  17. data/lib/morpheus/cli/commands/hosts.rb +72 -1
  18. data/lib/morpheus/cli/commands/instances.rb +12 -1
  19. data/lib/morpheus/cli/commands/networks_command.rb +35 -3
  20. data/lib/morpheus/cli/commands/virtual_switches_command.rb +336 -0
  21. data/lib/morpheus/cli/mixins/affinity_helper.rb +118 -0
  22. data/lib/morpheus/cli/mixins/infrastructure_helper.rb +143 -4
  23. data/lib/morpheus/cli/option_types.rb +36 -1
  24. data/lib/morpheus/cli/version.rb +1 -1
  25. data/test/api/affinity_groups_interface_test.rb +43 -0
  26. data/test/api/clouds_interface_test.rb +67 -0
  27. data/test/api/host_vm_groups_interface_test.rb +35 -0
  28. data/test/cli/affinity_groups_cloud_cluster_scope_test.rb +270 -0
  29. data/test/cli/affinity_groups_cluster_scope_test.rb +171 -0
  30. data/test/cli/affinity_groups_test.rb +57 -0
  31. data/test/cli/affinity_groups_violations_scope_test.rb +141 -0
  32. data/test/cli/affinity_helper_test.rb +367 -0
  33. data/test/cli/backups_test.rb +212 -0
  34. data/test/cli/clusters_uuid_test.rb +131 -0
  35. data/test/cli/distributed_workers_test.rb +68 -0
  36. data/test/cli/host_vm_groups_cluster_scope_test.rb +187 -0
  37. data/test/cli/host_vm_groups_test.rb +63 -0
  38. data/test/cli/infrastructure_helper_cluster_resolver_test.rb +250 -0
  39. 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
- json_response = networks_interface.list({name: name.to_s})
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)
@@ -1,6 +1,6 @@
1
1
 
2
2
  module Morpheus
3
3
  module Cli
4
- VERSION = "9.0.1"
4
+ VERSION = "9.1.0"
5
5
  end
6
6
  end
@@ -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