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.
- 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 +13 -0
- data/lib/morpheus/api/clouds_interface.rb +42 -0
- data/lib/morpheus/api/clusters_interface.rb +7 -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/cli/commands/affinity_groups_command.rb +554 -0
- data/lib/morpheus/cli/commands/backups_command.rb +1 -1
- data/lib/morpheus/cli/commands/clouds.rb +421 -29
- data/lib/morpheus/cli/commands/clusters.rb +37 -39
- 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/mixins/affinity_helper.rb +118 -0
- data/lib/morpheus/cli/mixins/infrastructure_helper.rb +137 -0
- 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 +37 -2
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
require 'test/unit'
|
|
2
|
+
$LOAD_PATH.unshift(File.expand_path('../../lib', __dir__))
|
|
3
|
+
require 'morpheus'
|
|
4
|
+
require 'morpheus/cli/commands/backups_command'
|
|
5
|
+
|
|
6
|
+
# Tests for Morpheus::Cli::BackupsCommand
|
|
7
|
+
#
|
|
8
|
+
# These tests focus on the human-readable backup detail rendering, which is
|
|
9
|
+
# pure formatting logic and does not require a live appliance. The detail view
|
|
10
|
+
# should prefer the nested API object names (backupType.name, backupProvider.name,
|
|
11
|
+
# storageProvider.name) and only fall back to legacy tag labels when absent.
|
|
12
|
+
class BackupsCommandTest < Test::Unit::TestCase
|
|
13
|
+
class RecordingBackupsInterface
|
|
14
|
+
attr_reader :created_payload
|
|
15
|
+
|
|
16
|
+
def initialize(backup_type_code)
|
|
17
|
+
@backup_type_code = backup_type_code
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def setopts(_options)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def create_options(_payload)
|
|
24
|
+
{
|
|
25
|
+
'containers' => [{'id' => 101, 'name' => 'web'}],
|
|
26
|
+
'backupTypes' => [{'code' => @backup_type_code, 'name' => 'Test Backup Type'}],
|
|
27
|
+
'backup' => {},
|
|
28
|
+
'backupSettings' => {},
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def create(payload)
|
|
33
|
+
@created_payload = Marshal.load(Marshal.dump(payload))
|
|
34
|
+
{'success' => true, 'backup' => {'id' => 99, 'name' => payload.dig('backup', 'name')}}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class RecordingOptionsInterface
|
|
39
|
+
attr_reader :requests
|
|
40
|
+
|
|
41
|
+
def initialize(plugin_option_types)
|
|
42
|
+
@plugin_option_types = plugin_option_types
|
|
43
|
+
@requests = []
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def options_for_source(source, params)
|
|
47
|
+
@requests << [source, params]
|
|
48
|
+
option_types = source == 'backupOptionTypes' ? @plugin_option_types : []
|
|
49
|
+
{'success' => true, 'data' => {'optionTypes' => option_types}}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class OfflineInstancesInterface
|
|
54
|
+
def list(_params)
|
|
55
|
+
{'instances' => [{'id' => 7, 'name' => 'test-instance'}]}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class OfflineBackupsCommand < Morpheus::Cli::BackupsCommand
|
|
60
|
+
attr_reader :backups_interface, :options_interface
|
|
61
|
+
|
|
62
|
+
def initialize(backup_type_code, plugin_option_types)
|
|
63
|
+
@backup_type_code = backup_type_code
|
|
64
|
+
@plugin_option_types = plugin_option_types
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def connect(_options)
|
|
68
|
+
@api_client = Object.new
|
|
69
|
+
@backups_interface = RecordingBackupsInterface.new(@backup_type_code)
|
|
70
|
+
@options_interface = RecordingOptionsInterface.new(@plugin_option_types)
|
|
71
|
+
@instances_interface = OfflineInstancesInterface.new
|
|
72
|
+
@servers_interface = Object.new
|
|
73
|
+
@backup_jobs_interface = Object.new
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def command
|
|
78
|
+
@command ||= Morpheus::Cli::BackupsCommand.new
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# snapshot backup with nested backupType/backupProvider/storageProvider names
|
|
82
|
+
def sample_backup
|
|
83
|
+
{
|
|
84
|
+
'id' => 132614,
|
|
85
|
+
'name' => 'vmware-snapshot-backup',
|
|
86
|
+
'backupType' => {'id' => 13, 'code' => 'vmwareSnapshot', 'name' => 'VMware VM Snapshot'},
|
|
87
|
+
'backupProvider' => {'id' => 543, 'code' => 'morpheus', 'name' => 'Internal Backup'},
|
|
88
|
+
'storageProvider' => {'id' => 194, 'name' => 'Local Backups'},
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def test_backup_detail_prefers_nested_names
|
|
93
|
+
columns = command.send(:backup_column_definitions)
|
|
94
|
+
backup = sample_backup
|
|
95
|
+
assert_equal 'VMware VM Snapshot', columns['Backup Type'].call(backup)
|
|
96
|
+
assert_equal 'Internal Backup', columns['Backup Provider'].call(backup)
|
|
97
|
+
assert_equal 'Local Backups', columns['Storage Provider'].call(backup)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# when backupType.name is absent, fall back to the legacy POLICY/MANUAL tag
|
|
101
|
+
def test_backup_type_falls_back_to_legacy_tag
|
|
102
|
+
columns = command.send(:backup_column_definitions)
|
|
103
|
+
manual_backup = {'id' => 1, 'name' => 'manual'}
|
|
104
|
+
assert_match(/MANUAL/, columns['Backup Type'].call(manual_backup))
|
|
105
|
+
|
|
106
|
+
policy_backup = {'id' => 2, 'name' => 'policy', 'job' => {'id' => 5}}
|
|
107
|
+
assert_match(/POLICY/, columns['Backup Type'].call(policy_backup))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# location tag should render the provider name instead of empty parens
|
|
111
|
+
def test_backup_location_tag_uses_provider_name
|
|
112
|
+
tag = command.send(:format_backup_location_tag, sample_backup)
|
|
113
|
+
assert_match(/REMOTE/, tag)
|
|
114
|
+
assert_match(/Local Backups/, tag)
|
|
115
|
+
refute_match(/\(\)/, tag)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def test_add_continues_when_backup_type_has_no_plugin_options
|
|
119
|
+
add_command = OfflineBackupsCommand.new('vmwareSnapshot', [])
|
|
120
|
+
|
|
121
|
+
with_backup_add_prompts('vmwareSnapshot') do
|
|
122
|
+
assert_equal [0, nil], add_command.add(['--quiet'])
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
backup_payload = add_command.backups_interface.created_payload['backup']
|
|
126
|
+
assert_equal 'vmwareSnapshot', backup_payload['backupType']
|
|
127
|
+
assert_equal 'addTo', backup_payload['jobAction']
|
|
128
|
+
assert_equal 44, backup_payload['jobId']
|
|
129
|
+
assert_equal ['backupOptionTypes', {'backupTypeCode' => 'vmwareSnapshot'}], add_command.options_interface.requests.first
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def test_add_flattens_plugin_domain_options_into_backup_payload
|
|
133
|
+
plugin_option_types = [
|
|
134
|
+
{
|
|
135
|
+
'fieldName' => 'pluginTarget',
|
|
136
|
+
'fieldLabel' => 'Plugin Target',
|
|
137
|
+
'fieldContext' => 'domain',
|
|
138
|
+
'type' => 'text',
|
|
139
|
+
'required' => true,
|
|
140
|
+
},
|
|
141
|
+
]
|
|
142
|
+
add_command = OfflineBackupsCommand.new('pluginBackup', plugin_option_types)
|
|
143
|
+
|
|
144
|
+
with_backup_add_prompts('pluginBackup', {'backup' => {'pluginTarget' => 'archive'}}) do
|
|
145
|
+
assert_equal [0, nil], add_command.add(['--quiet'])
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
backup_payload = add_command.backups_interface.created_payload['backup']
|
|
149
|
+
assert_equal 'archive', backup_payload['pluginTarget']
|
|
150
|
+
assert_false backup_payload.key?('backup')
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def test_add_continues_when_all_plugin_options_are_skipped
|
|
154
|
+
plugin_option_types = [
|
|
155
|
+
{
|
|
156
|
+
'fieldName' => 'pluginTarget',
|
|
157
|
+
'fieldLabel' => 'Plugin Target',
|
|
158
|
+
'fieldContext' => 'domain',
|
|
159
|
+
'type' => 'text',
|
|
160
|
+
'required' => false,
|
|
161
|
+
'dependsOnCode' => 'missingToggle:on',
|
|
162
|
+
},
|
|
163
|
+
]
|
|
164
|
+
add_command = OfflineBackupsCommand.new('conditionalPluginBackup', plugin_option_types)
|
|
165
|
+
|
|
166
|
+
with_backup_add_prompts('conditionalPluginBackup') do
|
|
167
|
+
assert_equal [0, nil], add_command.add(['--quiet'])
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
backup_payload = add_command.backups_interface.created_payload['backup']
|
|
171
|
+
assert_equal 'conditionalPluginBackup', backup_payload['backupType']
|
|
172
|
+
assert_equal 44, backup_payload['jobId']
|
|
173
|
+
assert_false backup_payload.key?('pluginTarget')
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
def with_backup_add_prompts(backup_type_code, plugin_values = {})
|
|
179
|
+
original_prompt = Morpheus::Cli::OptionTypes.method(:prompt)
|
|
180
|
+
Morpheus::Cli::OptionTypes.define_singleton_method(:prompt) do |option_types, options = {}, api_client = nil, *args|
|
|
181
|
+
if option_types.any? {|option_type| option_type['fieldContext'] == 'domain'}
|
|
182
|
+
prompt_options = options.deep_merge(plugin_values).merge(:no_prompt => true)
|
|
183
|
+
original_prompt.call(option_types, prompt_options, api_client, *args)
|
|
184
|
+
else
|
|
185
|
+
field_name = option_types.first && option_types.first['fieldName']
|
|
186
|
+
case field_name
|
|
187
|
+
when 'source'
|
|
188
|
+
{'source' => 'instance'}
|
|
189
|
+
when 'instanceId'
|
|
190
|
+
{'instanceId' => 7}
|
|
191
|
+
when 'name'
|
|
192
|
+
{'name' => 'offline-backup'}
|
|
193
|
+
when 'containerId'
|
|
194
|
+
{'containerId' => 101}
|
|
195
|
+
when 'backupType'
|
|
196
|
+
{'backupType' => backup_type_code}
|
|
197
|
+
when 'jobAction'
|
|
198
|
+
{'jobAction' => 'addTo'}
|
|
199
|
+
when 'jobId'
|
|
200
|
+
{'jobId' => 44}
|
|
201
|
+
when nil
|
|
202
|
+
{}
|
|
203
|
+
else
|
|
204
|
+
raise "Unexpected option prompt for #{field_name}"
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
yield
|
|
209
|
+
ensure
|
|
210
|
+
Morpheus::Cli::OptionTypes.define_singleton_method(:prompt, original_prompt)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -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
|