morpheus-cli 9.0.2 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/Dockerfile +1 -1
  3. data/lib/morpheus/api/affinity_groups_interface.rb +46 -0
  4. data/lib/morpheus/api/api_client.rb +13 -0
  5. data/lib/morpheus/api/clouds_interface.rb +42 -0
  6. data/lib/morpheus/api/clusters_interface.rb +7 -1
  7. data/lib/morpheus/api/distributed_workers_interface.rb +41 -0
  8. data/lib/morpheus/api/host_vm_groups_interface.rb +37 -0
  9. data/lib/morpheus/cli/commands/affinity_groups_command.rb +554 -0
  10. data/lib/morpheus/cli/commands/backups_command.rb +1 -1
  11. data/lib/morpheus/cli/commands/clouds.rb +421 -29
  12. data/lib/morpheus/cli/commands/clusters.rb +37 -39
  13. data/lib/morpheus/cli/commands/distributed_workers_command.rb +282 -0
  14. data/lib/morpheus/cli/commands/host_vm_groups_command.rb +423 -0
  15. data/lib/morpheus/cli/mixins/affinity_helper.rb +118 -0
  16. data/lib/morpheus/cli/mixins/infrastructure_helper.rb +137 -0
  17. data/lib/morpheus/cli/option_types.rb +36 -1
  18. data/lib/morpheus/cli/version.rb +1 -1
  19. data/test/api/affinity_groups_interface_test.rb +43 -0
  20. data/test/api/clouds_interface_test.rb +67 -0
  21. data/test/api/host_vm_groups_interface_test.rb +35 -0
  22. data/test/cli/affinity_groups_cloud_cluster_scope_test.rb +270 -0
  23. data/test/cli/affinity_groups_cluster_scope_test.rb +171 -0
  24. data/test/cli/affinity_groups_test.rb +57 -0
  25. data/test/cli/affinity_groups_violations_scope_test.rb +141 -0
  26. data/test/cli/affinity_helper_test.rb +367 -0
  27. data/test/cli/backups_test.rb +212 -0
  28. data/test/cli/clusters_uuid_test.rb +131 -0
  29. data/test/cli/distributed_workers_test.rb +68 -0
  30. data/test/cli/host_vm_groups_cluster_scope_test.rb +187 -0
  31. data/test/cli/host_vm_groups_test.rb +63 -0
  32. data/test/cli/infrastructure_helper_cluster_resolver_test.rb +250 -0
  33. metadata +37 -2
@@ -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.2"
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
@@ -0,0 +1,171 @@
1
+ require 'test/unit'
2
+ require 'stringio'
3
+ require 'morpheus'
4
+
5
+ class AffinityGroupsClusterScopeTest < Test::Unit::TestCase
6
+ class FakeCloudsInterface
7
+ attr_reader :calls
8
+
9
+ def initialize(cloud)
10
+ @cloud = cloud
11
+ @calls = []
12
+ @dry = false
13
+ end
14
+
15
+ def setopts(_options)
16
+ self
17
+ end
18
+
19
+ def dry
20
+ @dry = true
21
+ self
22
+ end
23
+
24
+ def get(id, params = {})
25
+ @calls << [:get, id, params]
26
+ {'zone' => @cloud}
27
+ end
28
+
29
+ def list(params = {})
30
+ @calls << [:list, params]
31
+ {'zones' => [@cloud]}
32
+ end
33
+
34
+ def list_affinity_groups(id, params = {})
35
+ @calls << [:list_affinity_groups, id, params]
36
+ return {'affinityGroups' => []} unless @dry
37
+
38
+ {method: :get, url: "/api/zones/#{id}/affinity-groups", params: params}
39
+ end
40
+ end
41
+
42
+ class FakeResourcePoolsInterface
43
+ attr_reader :calls
44
+
45
+ def initialize(pool)
46
+ @pool = pool
47
+ @calls = []
48
+ end
49
+
50
+ def get(cloud_id, id, params = {})
51
+ @calls << [:get, cloud_id, id, params]
52
+ {'resourcePool' => @pool}
53
+ end
54
+
55
+ def list(cloud_id, params = {})
56
+ @calls << [:list, cloud_id, params]
57
+ {'resourcePools' => [@pool]}
58
+ end
59
+
60
+ def list_without_cloud(params = {})
61
+ @calls << [:list_without_cloud, params]
62
+ {'resourcePools' => [@pool]}
63
+ end
64
+ end
65
+
66
+ class FakeClustersInterface
67
+ attr_reader :calls
68
+
69
+ def initialize(clusters = [])
70
+ @clusters = clusters
71
+ @calls = []
72
+ @dry = false
73
+ end
74
+
75
+ def setopts(_options)
76
+ self
77
+ end
78
+
79
+ def dry
80
+ @dry = true
81
+ self
82
+ end
83
+
84
+ def list(params = {})
85
+ @calls << [:list, params]
86
+ {'clusters' => @clusters.select {|cluster| cluster['name'] == params[:name]}}
87
+ end
88
+
89
+ def list_affinity_groups(id, params = {})
90
+ @calls << [:list_affinity_groups, id, params]
91
+ return {'affinityGroups' => []} unless @dry
92
+
93
+ {method: :get, url: "/api/clusters/#{id}/affinity-groups", params: params}
94
+ end
95
+ end
96
+
97
+ def setup
98
+ @cloud = {'id' => 59, 'name' => 'vCenter Cloud'}
99
+ @pool = {
100
+ 'id' => 501,
101
+ 'name' => 'QA',
102
+ 'type' => 'Cluster',
103
+ 'zone' => @cloud
104
+ }
105
+ end
106
+
107
+ def test_cloud_qualified_cluster_sends_pool_id
108
+ command, pools, = build_command
109
+
110
+ output = capture_stdout do
111
+ command.list(['--cloud', 'vCenter Cloud', '--cluster', 'QA', '--dry-run'])
112
+ end
113
+
114
+ assert_match(%r{/api/zones/59/affinity-groups}, output)
115
+ assert_match(/poolId=501/, output)
116
+ assert_equal [[:list, 59, {name: 'QA', type: 'Cluster'}]], pools.calls
117
+ end
118
+
119
+ def test_cluster_name_falls_back_to_unique_vsphere_cluster
120
+ command, pools, clusters = build_command
121
+
122
+ output = capture_stdout { command.list(['--cluster', 'QA', '--dry-run']) }
123
+
124
+ assert_match(%r{/api/zones/59/affinity-groups}, output)
125
+ assert_match(/poolId=501/, output)
126
+ assert_equal [[:list, {name: 'QA'}]], clusters.calls
127
+ assert_equal [
128
+ [:list_without_cloud, {name: 'QA', type: 'Cluster', max: 1000}]
129
+ ], pools.calls
130
+ end
131
+
132
+ def test_managed_cluster_takes_precedence
133
+ managed_cluster = {'id' => 8, 'name' => 'QA'}
134
+ command, pools, clusters = build_command([managed_cluster])
135
+
136
+ output = capture_stdout { command.list(['--cluster', 'QA', '--dry-run']) }
137
+
138
+ assert_match(%r{/api/clusters/8/affinity-groups}, output)
139
+ assert_equal [
140
+ [:list, {name: 'QA'}],
141
+ [:list_affinity_groups, 8, {}]
142
+ ], clusters.calls
143
+ assert_empty pools.calls
144
+ end
145
+
146
+ private
147
+
148
+ def build_command(managed_clusters = [])
149
+ clouds = FakeCloudsInterface.new(@cloud)
150
+ pools = FakeResourcePoolsInterface.new(@pool)
151
+ clusters = FakeClustersInterface.new(managed_clusters)
152
+ command = Morpheus::Cli::AffinityGroupsCommand.new
153
+ command.define_singleton_method(:connect) do |_options|
154
+ @clouds_interface = clouds
155
+ @clusters_interface = clusters
156
+ @cloud_resource_pools_interface = pools
157
+ end
158
+ [command, pools, clusters]
159
+ end
160
+
161
+ def capture_stdout
162
+ terminal = Morpheus::Terminal.instance
163
+ original = terminal.stdout
164
+ buffer = StringIO.new
165
+ terminal.set_stdout(buffer)
166
+ yield
167
+ buffer.string
168
+ ensure
169
+ terminal.set_stdout(original)
170
+ end
171
+ end