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
@@ -0,0 +1,131 @@
1
+ require 'test/unit'
2
+ require 'stringio'
3
+ require 'morpheus'
4
+
5
+ # Unit tests for MORPH-15042: cluster uuid support in the clusters command.
6
+ # - clusters get UUID resolves a single cluster via the path endpoint
7
+ # (/api/clusters/{uuid}), not the name query filter.
8
+ # - clusters list --uuid UUID [--uuid UUID ...] filters by one or more uuids.
9
+ #
10
+ # These follow the established pure-unit pattern (a fake ClustersInterface
11
+ # recording calls; Test::Unit::TestCase, no appliance/auth required). See
12
+ # test/cli/affinity_groups_cluster_scope_test.rb for the pattern this mirrors.
13
+ class ClustersUuidTest < Test::Unit::TestCase
14
+ # Records every call so tests can assert exactly what the command requested.
15
+ class FakeClustersInterface
16
+ attr_reader :get_calls, :list_calls
17
+
18
+ def initialize(clusters: [], get_result: nil)
19
+ @clusters = clusters
20
+ @get_result = get_result
21
+ @get_calls = []
22
+ @list_calls = []
23
+ @dry = false
24
+ end
25
+
26
+ def setopts(_options)
27
+ self
28
+ end
29
+
30
+ def dry
31
+ @dry = true
32
+ self
33
+ end
34
+
35
+ def get(arg)
36
+ @get_calls << arg
37
+ @get_result || {'cluster' => {'id' => 42, 'name' => 'k8s-1'}}
38
+ end
39
+
40
+ def list(params = {})
41
+ @list_calls << params
42
+ return {method: :get, url: '/api/clusters', params: params} if @dry
43
+ {'clusters' => @clusters.select {|c| c['name'] == params[:name] }}
44
+ end
45
+ end
46
+
47
+ def setup
48
+ @uuid = 'a1b2c3d4-e5f6-4711-8899-0011223344ff'
49
+ end
50
+
51
+ ## find_cluster_by_name_or_id dispatch
52
+
53
+ def test_uuid_resolves_via_path_get_not_name_list
54
+ command, clusters = build_command
55
+ cluster = command.send(:find_cluster_by_name_or_id, @uuid)
56
+ assert_equal 42, cluster['id']
57
+ # The uuid is handed to get as a String path segment, never a name list.
58
+ assert_equal [@uuid], clusters.get_calls
59
+ assert_empty clusters.list_calls
60
+ end
61
+
62
+ def test_numeric_id_still_resolves_via_path_get
63
+ command, clusters = build_command
64
+ command.send(:find_cluster_by_name_or_id, '7')
65
+ assert_equal [7], clusters.get_calls
66
+ assert_empty clusters.list_calls
67
+ end
68
+
69
+ def test_name_still_resolves_via_list_by_name
70
+ managed = {'id' => 9, 'name' => 'prod'}
71
+ command, clusters = build_command([managed])
72
+ cluster = command.send(:find_cluster_by_name_or_id, 'prod')
73
+ assert_equal 9, cluster['id']
74
+ assert_equal [{name: 'prod'}], clusters.list_calls
75
+ assert_empty clusters.get_calls
76
+ end
77
+
78
+ def test_cluster_uuid_predicate
79
+ command, = build_command
80
+ assert command.send(:cluster_uuid?, @uuid)
81
+ assert command.send(:cluster_uuid?, @uuid.upcase)
82
+ assert !command.send(:cluster_uuid?, '7')
83
+ assert !command.send(:cluster_uuid?, 'prod')
84
+ assert !command.send(:cluster_uuid?, 'a1b2c3d4-e5f6-4711-8899-0011223344')
85
+ end
86
+
87
+ ## clusters list --uuid
88
+
89
+ def test_list_with_single_uuid_carries_uuid_param
90
+ command, = build_command
91
+ output = capture_stdout { command.list(['--uuid', @uuid, '--dry-run']) }
92
+ assert_match(/#{Regexp.escape(@uuid)}/, output)
93
+ end
94
+
95
+ def test_list_with_multiple_uuids_carries_all_uuids
96
+ command, = build_command
97
+ other = 'ffeeddcc-bbaa-4998-8776-655443322110'
98
+ output = capture_stdout { command.list(['--uuid', @uuid, '--uuid', other, '--dry-run']) }
99
+ assert_match(/#{Regexp.escape(@uuid)}/, output)
100
+ assert_match(/#{Regexp.escape(other)}/, output)
101
+ end
102
+
103
+ def test_list_without_uuid_has_no_uuid_param
104
+ command, = build_command
105
+ output = capture_stdout { command.list(['--dry-run']) }
106
+ assert_no_match(/uuid/i, output)
107
+ end
108
+
109
+ private
110
+
111
+ def build_command(managed_clusters = [], get_result: nil)
112
+ clusters = FakeClustersInterface.new(clusters: managed_clusters, get_result: get_result)
113
+ command = Morpheus::Cli::Clusters.new
114
+ command.instance_variable_set(:@clusters_interface, clusters)
115
+ command.define_singleton_method(:connect) do |_options|
116
+ @clusters_interface = clusters
117
+ end
118
+ [command, clusters]
119
+ end
120
+
121
+ def capture_stdout
122
+ terminal = Morpheus::Terminal.instance
123
+ original = terminal.stdout
124
+ buffer = StringIO.new
125
+ terminal.set_stdout(buffer)
126
+ yield
127
+ buffer.string
128
+ ensure
129
+ terminal.set_stdout(original)
130
+ end
131
+ end
@@ -0,0 +1,68 @@
1
+ require 'morpheus_test'
2
+
3
+ # Tests for Morpheus::Cli::DistributedWorkers
4
+ class MorpheusTest::DistributedWorkersTest < MorpheusTest::TestCase
5
+
6
+ def test_distributed_workers_list
7
+ assert_execute %(distributed-workers list)
8
+ end
9
+
10
+ def test_distributed_workers_crud
11
+ worker_name = "cli-test-dw-#{rand(1000000)}"
12
+ created_id = nil
13
+ begin
14
+ # add and capture output to assert the apiKey is surfaced once
15
+ add_output = capture_stdout do
16
+ assert_execute %(distributed-workers add "#{worker_name}" --description "cli test worker" --enabled -N)
17
+ end
18
+ created_id = client.distributed_workers.list({name: worker_name})['distributedWorkers'].first['id']
19
+ assert_not_nil created_id, "Expected distributed worker to be created"
20
+
21
+ api_key = client.distributed_workers.list({name: worker_name})['distributedWorkers'].first['apiKey']
22
+ assert_nil api_key, "Expected apiKey to be omitted from list responses"
23
+ assert_match(/API Key/i, add_output, "Expected add output to surface the API Key")
24
+
25
+ # appears in list
26
+ assert_match(/#{Regexp.escape(worker_name)}/, capture_stdout { assert_execute %(distributed-workers list --json) })
27
+
28
+ # get by id and by name, apiKey must not be shown
29
+ get_output = capture_stdout do
30
+ assert_execute %(distributed-workers get "#{created_id}")
31
+ assert_execute %(distributed-workers get "#{escape_arg worker_name}")
32
+ end
33
+ refute_match(/API Key/i, get_output, "Expected get output to omit the API Key")
34
+
35
+ # update the description
36
+ assert_execute %(distributed-workers update "#{created_id}" --description "updated description")
37
+ updated = client.distributed_workers.get(created_id)['distributedWorker']
38
+ assert_equal "updated description", updated['description']
39
+
40
+ # remove and confirm it is gone
41
+ assert_execute %(distributed-workers remove "#{created_id}" -y)
42
+ assert_empty client.distributed_workers.list({name: worker_name})['distributedWorkers'],
43
+ "Expected distributed worker to be removed"
44
+ created_id = nil
45
+ ensure
46
+ if created_id
47
+ begin
48
+ client.distributed_workers.destroy(created_id)
49
+ rescue => e
50
+ puts "Failed to clean up distributed worker #{created_id}: #{e.message}"
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ protected
57
+
58
+ def capture_stdout
59
+ original = terminal.stdout
60
+ buffer = StringIO.new
61
+ terminal.set_stdout(buffer)
62
+ yield
63
+ buffer.string
64
+ ensure
65
+ terminal.set_stdout(original)
66
+ end
67
+
68
+ end
@@ -0,0 +1,187 @@
1
+ require 'test/unit'
2
+ require 'stringio'
3
+ require 'morpheus'
4
+
5
+ # Command-level offline exercise of the MORPH-17279 --cluster scope for
6
+ # HostVmGroupsCommand#list. Parallels affinity_groups_cluster_scope_test.rb:
7
+ # a bare --cluster name must resolve either to a managed cluster (POST/GET under
8
+ # /api/clusters/{id}/...) or, failing that, fall back to a cloud-owned vSphere
9
+ # cluster (ComputeZonePool type=Cluster) and scope the zone request with poolId.
10
+ # A name that matches neither must fail loudly, not exit 1 silently.
11
+ class HostVmGroupsClusterScopeTest < Test::Unit::TestCase
12
+ class FakeCloudsInterface
13
+ attr_reader :calls
14
+
15
+ def initialize(cloud)
16
+ @cloud = cloud
17
+ @calls = []
18
+ @dry = false
19
+ end
20
+
21
+ def setopts(_options)
22
+ self
23
+ end
24
+
25
+ def dry
26
+ @dry = true
27
+ self
28
+ end
29
+
30
+ def get(id, params = {})
31
+ @calls << [:get, id, params]
32
+ {'zone' => @cloud}
33
+ end
34
+
35
+ def list(params = {})
36
+ @calls << [:list, params]
37
+ {'zones' => [@cloud]}
38
+ end
39
+
40
+ def list_host_vm_groups(id, params = {})
41
+ @calls << [:list_host_vm_groups, id, params]
42
+ return {'hostVmGroups' => []} unless @dry
43
+
44
+ {method: :get, url: "/api/zones/#{id}/host-vm-groups", params: params}
45
+ end
46
+ end
47
+
48
+ class FakeResourcePoolsInterface
49
+ attr_reader :calls
50
+
51
+ def initialize(pools)
52
+ @pools = pools
53
+ @calls = []
54
+ end
55
+
56
+ def get(cloud_id, id, params = {})
57
+ @calls << [:get, cloud_id, id, params]
58
+ {'resourcePool' => @pools.find {|p| p['id'].to_i == id.to_i }}
59
+ end
60
+
61
+ def list(cloud_id, params = {})
62
+ @calls << [:list, cloud_id, params]
63
+ {'resourcePools' => @pools}
64
+ end
65
+
66
+ def list_without_cloud(params = {})
67
+ @calls << [:list_without_cloud, params]
68
+ {'resourcePools' => @pools}
69
+ end
70
+ end
71
+
72
+ class FakeClustersInterface
73
+ attr_reader :calls
74
+
75
+ def initialize(clusters = [])
76
+ @clusters = clusters
77
+ @calls = []
78
+ @dry = false
79
+ end
80
+
81
+ def setopts(_options)
82
+ self
83
+ end
84
+
85
+ def dry
86
+ @dry = true
87
+ self
88
+ end
89
+
90
+ def list(params = {})
91
+ @calls << [:list, params]
92
+ {'clusters' => @clusters.select {|cluster| cluster['name'] == params[:name]}}
93
+ end
94
+
95
+ def list_host_vm_groups(id, params = {})
96
+ @calls << [:list_host_vm_groups, id, params]
97
+ return {'hostVmGroups' => []} unless @dry
98
+
99
+ {method: :get, url: "/api/clusters/#{id}/host-vm-groups", params: params}
100
+ end
101
+ end
102
+
103
+ def setup
104
+ @cloud = {'id' => 59, 'name' => 'vCenter Cloud'}
105
+ @pool = {
106
+ 'id' => 501,
107
+ 'name' => 'QA',
108
+ 'type' => 'Cluster',
109
+ 'zone' => @cloud
110
+ }
111
+ end
112
+
113
+ def test_cluster_name_falls_back_to_unique_vsphere_cluster
114
+ command, pools, clusters = build_command
115
+
116
+ output = capture_stdout { command.list(['--cluster', 'QA', '--dry-run']) }
117
+
118
+ assert_match(%r{/api/zones/59/host-vm-groups}, output)
119
+ assert_match(/poolId=501/, output)
120
+ assert_equal [[:list, {name: 'QA'}]], clusters.calls
121
+ assert_equal [
122
+ [:list_without_cloud, {name: 'QA', type: 'Cluster', max: 1000}]
123
+ ], pools.calls
124
+ end
125
+
126
+ def test_managed_cluster_takes_precedence
127
+ managed_cluster = {'id' => 8, 'name' => 'QA'}
128
+ command, pools, clusters = build_command([managed_cluster])
129
+
130
+ output = capture_stdout { command.list(['--cluster', 'QA', '--dry-run']) }
131
+
132
+ assert_match(%r{/api/clusters/8/host-vm-groups}, output)
133
+ assert_equal [
134
+ [:list, {name: 'QA'}],
135
+ [:list_host_vm_groups, 8, {}]
136
+ ], clusters.calls
137
+ assert_empty pools.calls
138
+ end
139
+
140
+ def test_unknown_cluster_name_fails_loudly
141
+ command, _pools, _clusters = build_command([], [])
142
+
143
+ result = nil
144
+ err = capture_stderr do
145
+ capture_stdout { result = command.list(['--cluster', 'nope', '--dry-run']) }
146
+ end
147
+
148
+ assert_equal 1, (result.is_a?(Array) ? result[0] : result)
149
+ assert_match(/No managed or vSphere cluster found by name 'nope'/i, err)
150
+ end
151
+
152
+ private
153
+
154
+ def build_command(managed_clusters = [], pools = nil)
155
+ pools = [@pool] if pools.nil?
156
+ clouds = FakeCloudsInterface.new(@cloud)
157
+ pools_interface = FakeResourcePoolsInterface.new(pools)
158
+ clusters = FakeClustersInterface.new(managed_clusters)
159
+ command = Morpheus::Cli::HostVmGroupsCommand.new
160
+ command.define_singleton_method(:connect) do |_options|
161
+ @clouds_interface = clouds
162
+ @clusters_interface = clusters
163
+ @cloud_resource_pools_interface = pools_interface
164
+ end
165
+ [command, pools_interface, clusters]
166
+ end
167
+
168
+ def capture_stdout
169
+ terminal = Morpheus::Terminal.instance
170
+ original = terminal.stdout
171
+ buffer = StringIO.new
172
+ terminal.set_stdout(buffer)
173
+ yield
174
+ buffer.string
175
+ ensure
176
+ terminal.set_stdout(original)
177
+ end
178
+
179
+ def capture_stderr
180
+ original = $stderr
181
+ $stderr = StringIO.new
182
+ yield
183
+ $stderr.string
184
+ ensure
185
+ $stderr = original
186
+ end
187
+ end
@@ -0,0 +1,63 @@
1
+ require 'morpheus_test'
2
+
3
+ # Tests for Morpheus::Cli::HostVmGroupsCommand and the cloud-scoped
4
+ # host-vm-group subcommands added to Morpheus::Cli::Clouds for MORPH-16326.
5
+ #
6
+ # Scope note: these cover only client-side behaviour -- help output and argument
7
+ # validation -- both of which resolve before any API call is made.
8
+ #
9
+ # Tests that would exercise list/get/add/update/remove against the appliance are
10
+ # deliberately omitted until the /api/host-vm-groups request and response
11
+ # contract is confirmed against the MORPH-14136 / MORPH-16074 server work.
12
+ class MorpheusTest::HostVmGroupsTest < MorpheusTest::TestCase
13
+
14
+ def test_host_vm_groups_help
15
+ assert_execute %(host-vm-groups --help)
16
+ assert_execute %(host-vm-groups list --help)
17
+ assert_execute %(host-vm-groups get --help)
18
+ assert_execute %(host-vm-groups add --help)
19
+ assert_execute %(host-vm-groups update --help)
20
+ assert_execute %(host-vm-groups remove --help)
21
+ end
22
+
23
+ # The cloud-scoped subcommands must be registered on the clouds command.
24
+ def test_clouds_host_vm_group_subcommands_help
25
+ assert_execute %(clouds list-host-vm-groups --help)
26
+ assert_execute %(clouds get-host-vm-group --help)
27
+ assert_execute %(clouds add-host-vm-group --help)
28
+ assert_execute %(clouds update-host-vm-group --help)
29
+ assert_execute %(clouds remove-host-vm-group --help)
30
+ end
31
+
32
+ ## Argument validation (fails during option parsing, before any request)
33
+
34
+ def test_host_vm_groups_unknown_subcommand
35
+ assert_error %(host-vm-groups nonsense-subcommand)
36
+ end
37
+
38
+ def test_host_vm_groups_get_requires_an_id
39
+ assert_error %(host-vm-groups get)
40
+ end
41
+
42
+ def test_host_vm_groups_remove_requires_an_id
43
+ assert_error %(host-vm-groups remove -y)
44
+ end
45
+
46
+ def test_clouds_list_host_vm_groups_requires_a_cloud
47
+ assert_error %(clouds list-host-vm-groups)
48
+ end
49
+
50
+ def test_clouds_get_host_vm_group_requires_both_args
51
+ assert_error %(clouds get-host-vm-group 1)
52
+ end
53
+
54
+ # No unscoped list/create route at /api/host-vm-groups.
55
+ def test_host_vm_groups_list_requires_a_scope
56
+ assert_error %(host-vm-groups list)
57
+ end
58
+
59
+ def test_host_vm_groups_add_requires_a_scope
60
+ assert_error %(host-vm-groups add --name cli-test --dry-run)
61
+ end
62
+
63
+ end
@@ -0,0 +1,250 @@
1
+ require 'test/unit'
2
+ require 'stringio'
3
+ require 'json'
4
+ require 'morpheus'
5
+
6
+ # Unit tests for the MORPH-17279 cloud-owned vSphere cluster resolver added to
7
+ # Morpheus::Cli::InfrastructureHelper:
8
+ # find_resource_pool_cluster_by_name_or_id / _by_id / _by_name
9
+ #
10
+ # These are pure unit tests against a fake CloudResourcePoolsInterface. They
11
+ # deliberately do NOT extend MorpheusTest::TestCase because they require no
12
+ # appliance, no remote and no authentication - they must always be runnable.
13
+ # See test/cli/affinity_helper_test.rb for the established pattern this
14
+ # follows.
15
+ #
16
+ # Bug context: --cloud CLOUD --cluster CLUSTER must resolve CLUSTER as a
17
+ # ComputeZonePool(type='Cluster') owned by CLOUD, distinct from the managed
18
+ # cluster (ComputeServerGroup) namespace resolved via ClustersInterface. A
19
+ # name lookup that traverses ambiguous, wrong-cloud or wrong-type pools must
20
+ # fail loudly instead of returning nil silently or the wrong pool.
21
+ class InfrastructureHelperClusterResolverTest < Test::Unit::TestCase
22
+
23
+ # Fake standing in for Morpheus::CloudResourcePoolsInterface. Records every
24
+ # call so tests can assert exactly what was requested, mirroring the
25
+ # dry-run request assertions used elsewhere in this suite.
26
+ class FakeResourcePoolsInterface
27
+ attr_reader :get_calls, :list_calls
28
+
29
+ def initialize(get_result: nil, get_error: nil, list_result: nil)
30
+ @get_result = get_result
31
+ @get_error = get_error
32
+ @list_result = list_result
33
+ @get_calls = []
34
+ @list_calls = []
35
+ end
36
+
37
+ def get(cloud_id, id, params = {})
38
+ @get_calls << [cloud_id, id, params]
39
+ raise @get_error if @get_error
40
+ @get_result
41
+ end
42
+
43
+ def list(cloud_id, params = {})
44
+ @list_calls << [cloud_id, params]
45
+ @list_result
46
+ end
47
+ end
48
+
49
+ class FakeClustersInterface
50
+ def initialize(get_result: nil, get_error: nil, list_result: nil)
51
+ @get_result = get_result
52
+ @get_error = get_error
53
+ @list_result = list_result || {'clusters' => []}
54
+ end
55
+
56
+ def get(id)
57
+ raise @get_error if @get_error
58
+ @get_result
59
+ end
60
+
61
+ def list(params = {})
62
+ @list_result
63
+ end
64
+ end
65
+
66
+ # Bare host for the mixin under test.
67
+ class HelperHost
68
+ include Morpheus::Cli::InfrastructureHelper
69
+
70
+ def initialize(fake_interface, clusters_interface=nil)
71
+ @cloud_resource_pools_interface = fake_interface
72
+ @clusters_interface = clusters_interface if clusters_interface
73
+ end
74
+ end
75
+
76
+ def fake_not_found_error
77
+ response = Object.new
78
+ def response.code; 404; end
79
+ RestClient::Exception.new(response)
80
+ end
81
+
82
+ def fake_server_error
83
+ response = Object.new
84
+ def response.code; 500; end
85
+ RestClient::Exception.new(response)
86
+ end
87
+
88
+ def cloud
89
+ {'id' => 59, 'name' => 'vCenter Cloud'}
90
+ end
91
+
92
+ # print_red_alert writes straight to $stderr (print_helper.rb) - capture it.
93
+ def capture_stderr
94
+ original = $stderr
95
+ $stderr = StringIO.new
96
+ yield
97
+ $stderr.string
98
+ ensure
99
+ $stderr = original
100
+ end
101
+
102
+ ## find_resource_pool_cluster_by_id
103
+
104
+ def test_find_by_id_returns_the_pool_when_it_is_a_cluster_in_the_cloud
105
+ fake = FakeResourcePoolsInterface.new(get_result: {
106
+ 'resourcePool' => {'id' => 501, 'name' => 'QA', 'type' => 'Cluster', 'zone' => {'id' => 59, 'name' => 'vCenter Cloud'}}
107
+ })
108
+ helper = HelperHost.new(fake)
109
+ pool = helper.find_resource_pool_cluster_by_id(cloud, 501)
110
+ assert_not_nil pool
111
+ assert_equal 501, pool['id']
112
+ assert_equal 'QA', pool['name']
113
+ # Scoped to the cloud -- the server itself enforces ownership.
114
+ assert_equal [[59, 501, {}]], fake.get_calls
115
+ end
116
+
117
+ # ZoneResourcePoolsController#show 404s when the pool's zone does not match
118
+ # the requested cloud -- this is the server-side cloud-ownership check.
119
+ def test_find_by_id_reports_not_found_on_404_without_raising
120
+ fake = FakeResourcePoolsInterface.new(get_error: fake_not_found_error)
121
+ helper = HelperHost.new(fake)
122
+ pool = nil
123
+ output = capture_stderr { pool = helper.find_resource_pool_cluster_by_id(cloud, 999) }
124
+ assert_nil pool
125
+ assert_match(/not found by id 999/i, output)
126
+ assert_match(/vCenter Cloud/, output)
127
+ end
128
+
129
+ # A non-404 failure (auth, server error, etc.) must not be swallowed as a
130
+ # not-found -- that would hide a real problem behind a wrong error message.
131
+ def test_find_by_id_reraises_non_404_errors
132
+ fake = FakeResourcePoolsInterface.new(get_error: fake_server_error)
133
+ helper = HelperHost.new(fake)
134
+ assert_raise(RestClient::Exception) { helper.find_resource_pool_cluster_by_id(cloud, 501) }
135
+ end
136
+
137
+ def test_find_by_id_rejects_a_non_cluster_resource_pool
138
+ fake = FakeResourcePoolsInterface.new(get_result: {
139
+ 'resourcePool' => {'id' => 501, 'name' => 'Folder A', 'type' => 'ResourcePool', 'zone' => {'id' => 59, 'name' => 'vCenter Cloud'}}
140
+ })
141
+ helper = HelperHost.new(fake)
142
+ pool = nil
143
+ output = capture_stderr { pool = helper.find_resource_pool_cluster_by_id(cloud, 501) }
144
+ assert_nil pool
145
+ assert_match(/not a Cluster/i, output)
146
+ assert_match(/ResourcePool/, output)
147
+ end
148
+
149
+ ## find_resource_pool_cluster_by_name
150
+
151
+ def test_find_by_name_returns_the_single_match
152
+ fake = FakeResourcePoolsInterface.new(list_result: {
153
+ 'resourcePools' => [
154
+ {'id' => 501, 'name' => 'QA', 'type' => 'Cluster'},
155
+ ]
156
+ })
157
+ helper = HelperHost.new(fake)
158
+ pool = helper.find_resource_pool_cluster_by_name(cloud, 'QA')
159
+ assert_not_nil pool
160
+ assert_equal 501, pool['id']
161
+ assert_equal [[59, {name: 'QA', type: 'Cluster'}]], fake.list_calls
162
+ end
163
+
164
+ def test_find_by_name_matches_on_display_name
165
+ fake = FakeResourcePoolsInterface.new(list_result: {
166
+ 'resourcePools' => [
167
+ {'id' => 501, 'name' => 'internal-qa-01', 'displayName' => 'QA', 'type' => 'Cluster'},
168
+ ]
169
+ })
170
+ helper = HelperHost.new(fake)
171
+ pool = helper.find_resource_pool_cluster_by_name(cloud, 'QA')
172
+ assert_not_nil pool
173
+ assert_equal 501, pool['id']
174
+ end
175
+
176
+ def test_find_by_name_reports_not_found_with_a_managed_cluster_hint
177
+ fake = FakeResourcePoolsInterface.new(list_result: {'resourcePools' => []})
178
+ helper = HelperHost.new(fake)
179
+ pool = nil
180
+ output = capture_stderr { pool = helper.find_resource_pool_cluster_by_name(cloud, 'QA') }
181
+ assert_nil pool
182
+ assert_match(/not found by name 'QA'/i, output)
183
+ assert_match(/vCenter Cloud/, output)
184
+ assert_match(/--cluster 'QA' without --cloud/, output)
185
+ end
186
+
187
+ # The cloud-scoped resource-pools endpoint returns a pool *tree*: when the
188
+ # matched pool has a parent folder, ancestor entries are backfilled into the
189
+ # result so the hierarchy renders, even though they do not match the
190
+ # name/type filter. The resolver must not misreport this as ambiguity.
191
+ def test_find_by_name_ignores_ancestor_tree_padding
192
+ fake = FakeResourcePoolsInterface.new(list_result: {
193
+ 'resourcePools' => [
194
+ {'id' => 10, 'name' => 'Datacenter Folder', 'type' => 'Folder', 'depth' => 0},
195
+ {'id' => 501, 'name' => 'QA', 'type' => 'Cluster', 'depth' => 1},
196
+ ]
197
+ })
198
+ helper = HelperHost.new(fake)
199
+ pool = helper.find_resource_pool_cluster_by_name(cloud, 'QA')
200
+ assert_not_nil pool, "the ancestor folder must not be counted as a second match"
201
+ assert_equal 501, pool['id']
202
+ end
203
+
204
+ def test_find_by_name_reports_ambiguity_with_candidates_and_requires_numeric_id
205
+ fake = FakeResourcePoolsInterface.new(list_result: {
206
+ 'resourcePools' => [
207
+ {'id' => 501, 'name' => 'QA', 'type' => 'Cluster'},
208
+ {'id' => 502, 'name' => 'QA', 'type' => 'Cluster'},
209
+ ]
210
+ })
211
+ helper = HelperHost.new(fake)
212
+ pool = nil
213
+ output = capture_stderr { pool = helper.find_resource_pool_cluster_by_name(cloud, 'QA') }
214
+ assert_nil pool
215
+ assert_match(/2 clusters found by name 'QA'/i, output)
216
+ assert_match(/numeric cluster id/i, output)
217
+ end
218
+
219
+ ## find_resource_pool_cluster_by_name_or_id dispatch
220
+
221
+ def test_find_by_name_or_id_dispatches_numeric_strings_to_by_id
222
+ fake = FakeResourcePoolsInterface.new(get_result: {
223
+ 'resourcePool' => {'id' => 501, 'name' => 'QA', 'type' => 'Cluster', 'zone' => {'id' => 59}}
224
+ })
225
+ helper = HelperHost.new(fake)
226
+ pool = helper.find_resource_pool_cluster_by_name_or_id(cloud, '501')
227
+ assert_not_nil pool
228
+ assert_equal 1, fake.get_calls.size
229
+ assert_equal 0, fake.list_calls.size
230
+ end
231
+
232
+ def test_find_by_name_or_id_dispatches_non_numeric_strings_to_by_name
233
+ fake = FakeResourcePoolsInterface.new(list_result: {
234
+ 'resourcePools' => [{'id' => 501, 'name' => 'QA', 'type' => 'Cluster'}]
235
+ })
236
+ helper = HelperHost.new(fake)
237
+ pool = helper.find_resource_pool_cluster_by_name_or_id(cloud, 'QA')
238
+ assert_not_nil pool
239
+ assert_equal 0, fake.get_calls.size
240
+ assert_equal 1, fake.list_calls.size
241
+ end
242
+
243
+ def test_managed_cluster_lookup_reraises_non_404_errors
244
+ pools = FakeResourcePoolsInterface.new
245
+ clusters = FakeClustersInterface.new(get_error: fake_server_error)
246
+ helper = HelperHost.new(pools, clusters)
247
+ assert_raise(RestClient::Exception) { helper.find_managed_cluster_by_name_or_id('8') }
248
+ end
249
+
250
+ end