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,367 @@
|
|
|
1
|
+
require 'test/unit'
|
|
2
|
+
require 'stringio'
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'morpheus'
|
|
5
|
+
|
|
6
|
+
# Unit tests for Morpheus::Cli::AffinityHelper
|
|
7
|
+
#
|
|
8
|
+
# These are pure unit tests of the shared affinity enums and formatters.
|
|
9
|
+
# They deliberately do NOT extend MorpheusTest::TestCase because they require
|
|
10
|
+
# no appliance, no remote and no authentication - they must always be runnable.
|
|
11
|
+
#
|
|
12
|
+
# These tests exist to guard a real regression: the affinity type enum was
|
|
13
|
+
# previously duplicated across clouds.rb and clusters.rb and the two drifted,
|
|
14
|
+
# leaving the cloud-scoped command able to offer only 2 of the 4 valid types
|
|
15
|
+
# and silently rendering mandatory _MUST constraints as soft ones.
|
|
16
|
+
class AffinityHelperTest < Test::Unit::TestCase
|
|
17
|
+
|
|
18
|
+
# Bare host for the mixin under test
|
|
19
|
+
class HelperHost
|
|
20
|
+
include Morpheus::Cli::AffinityHelper
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def setup
|
|
24
|
+
@helper = HelperHost.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
## Enums
|
|
28
|
+
|
|
29
|
+
# The canonical list, per morpheus-ui. If this test fails, the CLI, the UI and
|
|
30
|
+
# the OpenAPI affinity-type schema have drifted apart.
|
|
31
|
+
def test_affinity_types_contains_all_four_values
|
|
32
|
+
expected = %w[KEEP_TOGETHER KEEP_SEPARATE KEEP_TOGETHER_MUST KEEP_SEPARATE_MUST]
|
|
33
|
+
actual = @helper.affinity_type_select_options.collect {|it| it['value'] }
|
|
34
|
+
assert_equal expected.sort, actual.sort,
|
|
35
|
+
"Affinity types drifted from morpheus-ui. Expected exactly: #{expected.sort.inspect}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def test_affinity_types_includes_mandatory_must_variants
|
|
39
|
+
values = @helper.affinity_type_select_options.collect {|it| it['value'] }
|
|
40
|
+
assert values.include?('KEEP_TOGETHER_MUST'), "KEEP_TOGETHER_MUST must be offered"
|
|
41
|
+
assert values.include?('KEEP_SEPARATE_MUST'), "KEEP_SEPARATE_MUST must be offered"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Per HostVmGroup.HostVmGroupType, morpheus-ui rc-9.1.0
|
|
45
|
+
# (morpheus-domains/grails-app/domain/com/morpheus/HostVmGroup.groovy:32).
|
|
46
|
+
def test_host_vm_group_types
|
|
47
|
+
expected = %w[SITE_GROUP HOST_GROUP VM_GROUP]
|
|
48
|
+
actual = @helper.host_vm_group_type_select_options.collect {|it| it['value'] }
|
|
49
|
+
assert_equal expected.sort, actual.sort
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Every select option must carry a display name, or the prompt renders blanks.
|
|
53
|
+
def test_all_select_options_have_name_and_value
|
|
54
|
+
[
|
|
55
|
+
@helper.affinity_type_select_options,
|
|
56
|
+
@helper.host_vm_group_type_select_options,
|
|
57
|
+
].each do |options|
|
|
58
|
+
options.each do |opt|
|
|
59
|
+
assert_not_nil opt['name'], "select option missing 'name': #{opt.inspect}"
|
|
60
|
+
assert_not_nil opt['value'], "select option missing 'value': #{opt.inspect}"
|
|
61
|
+
assert !opt['name'].to_s.empty?, "select option has empty 'name': #{opt.inspect}"
|
|
62
|
+
assert !opt['value'].to_s.empty?, "select option has empty 'value': #{opt.inspect}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Callers mutate the hashes they get back (adding required/defaultValue),
|
|
68
|
+
# so the frozen constants must never be handed out directly.
|
|
69
|
+
def test_select_options_are_defensive_copies
|
|
70
|
+
first = @helper.affinity_type_select_options
|
|
71
|
+
first[0]['name'] = 'MUTATED'
|
|
72
|
+
second = @helper.affinity_type_select_options
|
|
73
|
+
assert_not_equal 'MUTATED', second[0]['name'],
|
|
74
|
+
"select options leaked a reference to the shared constant"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
## Formatting
|
|
78
|
+
|
|
79
|
+
def test_format_affinity_type_renders_all_four
|
|
80
|
+
assert_equal 'Keep Together', @helper.format_affinity_type('KEEP_TOGETHER')
|
|
81
|
+
assert_equal 'Keep Separate', @helper.format_affinity_type('KEEP_SEPARATE')
|
|
82
|
+
assert_equal 'Keep Together (Must)', @helper.format_affinity_type('KEEP_TOGETHER_MUST')
|
|
83
|
+
assert_equal 'Keep Separate (Must)', @helper.format_affinity_type('KEEP_SEPARATE_MUST')
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The original bug: a _MUST value rendered as the plain soft label, so a
|
|
87
|
+
# mandatory constraint was displayed as if it were advisory.
|
|
88
|
+
def test_format_affinity_type_distinguishes_must_from_should
|
|
89
|
+
assert_not_equal @helper.format_affinity_type('KEEP_TOGETHER'),
|
|
90
|
+
@helper.format_affinity_type('KEEP_TOGETHER_MUST'),
|
|
91
|
+
"KEEP_TOGETHER_MUST must not render identically to KEEP_TOGETHER"
|
|
92
|
+
assert_not_equal @helper.format_affinity_type('KEEP_SEPARATE'),
|
|
93
|
+
@helper.format_affinity_type('KEEP_SEPARATE_MUST'),
|
|
94
|
+
"KEEP_SEPARATE_MUST must not render identically to KEEP_SEPARATE"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Unknown/future values must pass through rather than render blank.
|
|
98
|
+
def test_format_affinity_type_passes_through_unknown
|
|
99
|
+
assert_equal 'SOME_FUTURE_TYPE', @helper.format_affinity_type('SOME_FUTURE_TYPE')
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def test_format_affinity_type_handles_nil_and_empty
|
|
103
|
+
assert_equal '', @helper.format_affinity_type(nil)
|
|
104
|
+
assert_equal '', @helper.format_affinity_type('')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def test_format_host_vm_group_type
|
|
108
|
+
assert_equal 'Site Group', @helper.format_host_vm_group_type('SITE_GROUP')
|
|
109
|
+
assert_equal 'Host Group', @helper.format_host_vm_group_type('HOST_GROUP')
|
|
110
|
+
assert_equal 'VM Group', @helper.format_host_vm_group_type('VM_GROUP')
|
|
111
|
+
assert_equal 'OTHER', @helper.format_host_vm_group_type('OTHER')
|
|
112
|
+
assert_equal '', @helper.format_host_vm_group_type(nil)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Every enum value must round-trip to a human label, never fall through
|
|
116
|
+
# to the raw passthrough branch.
|
|
117
|
+
def test_every_enum_value_has_a_friendly_label
|
|
118
|
+
@helper.affinity_type_select_options.each do |opt|
|
|
119
|
+
assert_not_equal opt['value'], @helper.format_affinity_type(opt['value']),
|
|
120
|
+
"#{opt['value']} has no friendly label"
|
|
121
|
+
end
|
|
122
|
+
@helper.host_vm_group_type_select_options.each do |opt|
|
|
123
|
+
assert_not_equal opt['value'], @helper.format_host_vm_group_type(opt['value']),
|
|
124
|
+
"#{opt['value']} has no friendly label"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
## Error handling
|
|
129
|
+
|
|
130
|
+
# Fake RestClient-style exception carrying a JSON body.
|
|
131
|
+
class FakeResponse
|
|
132
|
+
def initialize(body); @body = body; end
|
|
133
|
+
def to_s; @body; end
|
|
134
|
+
end
|
|
135
|
+
class FakeRestException < StandardError
|
|
136
|
+
attr_reader :response
|
|
137
|
+
def initialize(body); @response = FakeResponse.new(body); end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Errors are written to stderr (print_red_alert), so capture that stream.
|
|
141
|
+
def capture_error_output(body)
|
|
142
|
+
out = StringIO.new
|
|
143
|
+
orig = $stderr
|
|
144
|
+
$stderr = out
|
|
145
|
+
begin
|
|
146
|
+
@helper.handle_affinity_rest_exception(FakeRestException.new(body), {})
|
|
147
|
+
ensure
|
|
148
|
+
$stderr = orig
|
|
149
|
+
end
|
|
150
|
+
out.string
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Real payload shape from AffinityGroupService.validateCombinedRulePoolConsistency
|
|
154
|
+
# (morpheus-ui rc-9.1.0). There is no top-level errorCode - the CLI must read
|
|
155
|
+
# msg and the field-keyed errors map.
|
|
156
|
+
def test_cross_pool_error_surfaces_server_message
|
|
157
|
+
body = {
|
|
158
|
+
'success' => false,
|
|
159
|
+
'msg' => "VM Group 'VMG-1' and Host Group 'HG-1' belong to different clusters",
|
|
160
|
+
'errors' => {'hostGroup' => 'affinityGroup.combinedRule.crossClusterHostGroup'}
|
|
161
|
+
}.to_json
|
|
162
|
+
output = capture_error_output(body)
|
|
163
|
+
assert output.include?("belong to different clusters"),
|
|
164
|
+
"cross-pool error must surface the server msg, got: #{output.inspect}"
|
|
165
|
+
assert output.include?('affinityGroup.combinedRule.crossClusterHostGroup'),
|
|
166
|
+
"cross-pool error must surface the field error key, got: #{output.inspect}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Real payload from VmwareComputeService.combinedRuleGroupSyncGuard.
|
|
170
|
+
def test_unsynced_group_error_surfaces_server_message
|
|
171
|
+
body = {
|
|
172
|
+
'success' => false,
|
|
173
|
+
'msg' => "Cannot save combined VM-to-Host rule 'r1' - referenced group(s) have not yet " \
|
|
174
|
+
"synced to vSphere: VM Group 'VMG-local'. Wait for the next cloud refresh.",
|
|
175
|
+
'errors' => {'affinityGroup' => 'errors.affinity.rule.group.not.synced'}
|
|
176
|
+
}.to_json
|
|
177
|
+
output = capture_error_output(body)
|
|
178
|
+
assert output.include?("have not yet synced to vSphere"),
|
|
179
|
+
"unsynced error must surface the server msg, got: #{output.inspect}"
|
|
180
|
+
assert output.include?('errors.affinity.rule.group.not.synced'),
|
|
181
|
+
"unsynced error must surface the field error key, got: #{output.inspect}"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# A body with errors but no msg must still report the field errors.
|
|
185
|
+
def test_field_errors_are_reported_without_a_msg
|
|
186
|
+
body = {
|
|
187
|
+
'success' => false,
|
|
188
|
+
'errors' => {'vmGroup' => 'affinityGroup.combinedRule.bothGroupsRequired'}
|
|
189
|
+
}.to_json
|
|
190
|
+
output = capture_error_output(body)
|
|
191
|
+
assert output.include?('affinityGroup.combinedRule.bothGroupsRequired'),
|
|
192
|
+
"field errors must be reported even with no msg, got: #{output.inspect}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# HostVmGroupsController#save renders flat JSON with msg and no errors map
|
|
196
|
+
# for the multiple-clusters / pool-mismatch guards. Must still surface.
|
|
197
|
+
def test_msg_only_error_body_is_surfaced
|
|
198
|
+
body = {
|
|
199
|
+
'success' => false,
|
|
200
|
+
'msg' => 'Selected servers span multiple clusters. A Host / VM Group must belong to a single cluster.'
|
|
201
|
+
}.to_json
|
|
202
|
+
output = capture_error_output(body)
|
|
203
|
+
assert output.include?('span multiple clusters'),
|
|
204
|
+
"a body with msg but no errors map must still be surfaced, got: #{output.inspect}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
## Cluster-in-cloud narrowing
|
|
208
|
+
|
|
209
|
+
# formOptions returns groups with a poolId; a cloud-scoped rule belongs to a
|
|
210
|
+
# single cluster, so only groups in the chosen cluster may be offered.
|
|
211
|
+
def test_filter_by_pool_keeps_only_matching_groups
|
|
212
|
+
rows = [
|
|
213
|
+
{'id' => 1, 'name' => 'vmg-a', 'poolId' => 10},
|
|
214
|
+
{'id' => 2, 'name' => 'vmg-b', 'poolId' => 20},
|
|
215
|
+
]
|
|
216
|
+
assert_equal [1], @helper.filter_by_pool(rows, 10).collect {|r| r['id'] }
|
|
217
|
+
assert_equal [2], @helper.filter_by_pool(rows, 20).collect {|r| r['id'] }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Groups with no poolId are kept -- the server did the narrowing already.
|
|
221
|
+
def test_filter_by_pool_keeps_rows_without_a_pool_id
|
|
222
|
+
rows = [{'id' => 1, 'poolId' => nil}, {'id' => 2, 'poolId' => 99}]
|
|
223
|
+
assert_equal [1], @helper.filter_by_pool(rows, 7).collect {|r| r['id'] }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def test_filter_by_pool_is_a_noop_without_a_pool
|
|
227
|
+
rows = [{'id' => 1, 'poolId' => 10}, {'id' => 2, 'poolId' => 20}]
|
|
228
|
+
assert_equal rows, @helper.filter_by_pool(rows, nil)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Pool ids may arrive as String from CLI flags and Integer from JSON.
|
|
232
|
+
def test_filter_by_pool_compares_across_types
|
|
233
|
+
rows = [{'id' => 1, 'poolId' => 10}]
|
|
234
|
+
assert_equal 1, @helper.filter_by_pool(rows, '10').size
|
|
235
|
+
assert_equal 1, @helper.filter_by_pool(rows, 10).size
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
## Option types
|
|
239
|
+
|
|
240
|
+
def test_affinity_type_option_type_defaults_to_optional
|
|
241
|
+
opt = @helper.affinity_type_option_type
|
|
242
|
+
assert_equal 'affinityType', opt['fieldName']
|
|
243
|
+
assert_equal 'select', opt['type']
|
|
244
|
+
assert_nil opt['required'], "affinityType must be optional unless explicitly required"
|
|
245
|
+
assert_nil opt['defaultValue'], "affinityType must have no default unless explicitly given"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def test_affinity_type_option_type_accepts_required_and_default
|
|
249
|
+
opt = @helper.affinity_type_option_type(required: true, default: 'KEEP_TOGETHER')
|
|
250
|
+
assert_equal true, opt['required']
|
|
251
|
+
assert_equal 'KEEP_TOGETHER', opt['defaultValue']
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Any default must itself be a member of the enum.
|
|
255
|
+
def test_option_type_defaults_are_valid_enum_members
|
|
256
|
+
values = @helper.affinity_type_select_options.collect {|it| it['value'] }
|
|
257
|
+
%w[KEEP_TOGETHER KEEP_SEPARATE].each do |default|
|
|
258
|
+
opt = @helper.affinity_type_option_type(required: true, default: default)
|
|
259
|
+
assert values.include?(opt['defaultValue']),
|
|
260
|
+
"default #{default} is not a valid affinity type"
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def test_host_vm_group_type_option_type
|
|
265
|
+
opt = @helper.host_vm_group_type_option_type(required: true, default: 'HOST_GROUP')
|
|
266
|
+
assert_equal 'type', opt['fieldName']
|
|
267
|
+
assert_equal true, opt['required']
|
|
268
|
+
assert_equal 'HOST_GROUP', opt['defaultValue']
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
## Wiring
|
|
272
|
+
|
|
273
|
+
# affinityType must be editable on update, not create-only.
|
|
274
|
+
# AffinityGroupService.updateAffinityGroup applies params.affinityGroup.affinityType,
|
|
275
|
+
# so callers may switch between MUST and non-MUST behaviour during an edit.
|
|
276
|
+
def test_affinity_type_is_editable_on_update_in_all_commands
|
|
277
|
+
[
|
|
278
|
+
Morpheus::Cli::AffinityGroupsCommand,
|
|
279
|
+
Morpheus::Cli::Clouds,
|
|
280
|
+
Morpheus::Cli::Clusters,
|
|
281
|
+
].each do |klass|
|
|
282
|
+
cmd = klass.new
|
|
283
|
+
method_name = all_own_methods(klass).find {|m|
|
|
284
|
+
m.to_s =~ /\Aupdate_(cloud_)?affinity_group_option_types\z/
|
|
285
|
+
}
|
|
286
|
+
assert_not_nil method_name, "#{klass} has no update affinity option types method"
|
|
287
|
+
option_types = cmd.send(method_name)
|
|
288
|
+
field_names = option_types.collect {|it| it['fieldName'] }
|
|
289
|
+
assert field_names.include?('affinityType'),
|
|
290
|
+
"#{klass}##{method_name} omits affinityType - it is editable, not create-only"
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Guards against a command re-introducing a local copy of the enum.
|
|
295
|
+
def test_commands_offer_all_four_types_on_add_and_update
|
|
296
|
+
expected = %w[KEEP_TOGETHER KEEP_SEPARATE KEEP_TOGETHER_MUST KEEP_SEPARATE_MUST].sort
|
|
297
|
+
[
|
|
298
|
+
[Morpheus::Cli::AffinityGroupsCommand, :add_affinity_group_option_types],
|
|
299
|
+
[Morpheus::Cli::AffinityGroupsCommand, :update_affinity_group_option_types],
|
|
300
|
+
[Morpheus::Cli::Clouds, :add_cloud_affinity_group_option_types],
|
|
301
|
+
[Morpheus::Cli::Clouds, :update_cloud_affinity_group_option_types],
|
|
302
|
+
[Morpheus::Cli::Clusters, :add_affinity_group_option_types],
|
|
303
|
+
[Morpheus::Cli::Clusters, :update_affinity_group_option_types],
|
|
304
|
+
].each do |klass, method_name|
|
|
305
|
+
option_types = klass.new.send(method_name)
|
|
306
|
+
affinity_field = option_types.find {|it| it['fieldName'] == 'affinityType' }
|
|
307
|
+
assert_not_nil affinity_field, "#{klass}##{method_name} has no affinityType field"
|
|
308
|
+
actual = affinity_field['selectOptions'].collect {|it| it['value'] }.sort
|
|
309
|
+
assert_equal expected, actual,
|
|
310
|
+
"#{klass}##{method_name} does not offer all four affinity types"
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# The enums and formatters must exist in exactly one place.
|
|
315
|
+
def test_commands_do_not_redefine_shared_helpers
|
|
316
|
+
[
|
|
317
|
+
Morpheus::Cli::AffinityGroupsCommand,
|
|
318
|
+
Morpheus::Cli::HostVmGroupsCommand,
|
|
319
|
+
Morpheus::Cli::Clouds,
|
|
320
|
+
Morpheus::Cli::Clusters,
|
|
321
|
+
].each do |klass|
|
|
322
|
+
own_methods = all_own_methods(klass)
|
|
323
|
+
[:format_affinity_type, :format_host_vm_group_type, :handle_affinity_rest_exception].each do |shared|
|
|
324
|
+
assert !own_methods.include?(shared),
|
|
325
|
+
"#{klass} redefines #{shared} - use Morpheus::Cli::AffinityHelper instead to prevent drift"
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def test_affinity_commands_include_the_shared_helper
|
|
331
|
+
[
|
|
332
|
+
Morpheus::Cli::AffinityGroupsCommand,
|
|
333
|
+
Morpheus::Cli::HostVmGroupsCommand,
|
|
334
|
+
Morpheus::Cli::Clouds,
|
|
335
|
+
Morpheus::Cli::Clusters,
|
|
336
|
+
].each do |klass|
|
|
337
|
+
assert klass.include?(Morpheus::Cli::AffinityHelper),
|
|
338
|
+
"#{klass} does not include Morpheus::Cli::AffinityHelper"
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Cloud-scoped affinity groups and host/VM groups both belong to exactly one
|
|
343
|
+
# cluster, so both add forms must offer the cluster picker. Without it the
|
|
344
|
+
# user can pick servers spanning clusters and the server rejects the create.
|
|
345
|
+
def test_cloud_scoped_add_forms_offer_a_cluster_picker
|
|
346
|
+
[
|
|
347
|
+
:add_cloud_affinity_group_option_types,
|
|
348
|
+
:add_cloud_host_vm_group_option_types,
|
|
349
|
+
].each do |method_name|
|
|
350
|
+
option_types = Morpheus::Cli::Clouds.new.send(method_name)
|
|
351
|
+
pool_field = option_types.find {|it| it['fieldName'] == 'pool.id' }
|
|
352
|
+
assert_not_nil pool_field,
|
|
353
|
+
"Morpheus::Cli::Clouds##{method_name} has no cluster picker (pool.id)"
|
|
354
|
+
assert_equal true, pool_field['required'],
|
|
355
|
+
"#{method_name}: the cluster picker must be required"
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
protected
|
|
360
|
+
|
|
361
|
+
# Option-type builders are private in the command classes, so reflection must
|
|
362
|
+
# consider private methods too.
|
|
363
|
+
def all_own_methods(klass)
|
|
364
|
+
klass.instance_methods(false) + klass.private_instance_methods(false)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
end
|
|
@@ -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
|