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
|
@@ -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
|