morpheus-cli 9.0.2 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/Dockerfile +1 -1
  3. data/lib/morpheus/api/affinity_groups_interface.rb +46 -0
  4. data/lib/morpheus/api/api_client.rb +13 -0
  5. data/lib/morpheus/api/clouds_interface.rb +42 -0
  6. data/lib/morpheus/api/clusters_interface.rb +7 -1
  7. data/lib/morpheus/api/distributed_workers_interface.rb +41 -0
  8. data/lib/morpheus/api/host_vm_groups_interface.rb +37 -0
  9. data/lib/morpheus/cli/commands/affinity_groups_command.rb +554 -0
  10. data/lib/morpheus/cli/commands/backups_command.rb +1 -1
  11. data/lib/morpheus/cli/commands/clouds.rb +421 -29
  12. data/lib/morpheus/cli/commands/clusters.rb +37 -39
  13. data/lib/morpheus/cli/commands/distributed_workers_command.rb +282 -0
  14. data/lib/morpheus/cli/commands/host_vm_groups_command.rb +423 -0
  15. data/lib/morpheus/cli/mixins/affinity_helper.rb +118 -0
  16. data/lib/morpheus/cli/mixins/infrastructure_helper.rb +137 -0
  17. data/lib/morpheus/cli/option_types.rb +36 -1
  18. data/lib/morpheus/cli/version.rb +1 -1
  19. data/test/api/affinity_groups_interface_test.rb +43 -0
  20. data/test/api/clouds_interface_test.rb +67 -0
  21. data/test/api/host_vm_groups_interface_test.rb +35 -0
  22. data/test/cli/affinity_groups_cloud_cluster_scope_test.rb +270 -0
  23. data/test/cli/affinity_groups_cluster_scope_test.rb +171 -0
  24. data/test/cli/affinity_groups_test.rb +57 -0
  25. data/test/cli/affinity_groups_violations_scope_test.rb +141 -0
  26. data/test/cli/affinity_helper_test.rb +367 -0
  27. data/test/cli/backups_test.rb +212 -0
  28. data/test/cli/clusters_uuid_test.rb +131 -0
  29. data/test/cli/distributed_workers_test.rb +68 -0
  30. data/test/cli/host_vm_groups_cluster_scope_test.rb +187 -0
  31. data/test/cli/host_vm_groups_test.rb +63 -0
  32. data/test/cli/infrastructure_helper_cluster_resolver_test.rb +250 -0
  33. metadata +37 -2
@@ -0,0 +1,57 @@
1
+ require 'morpheus_test'
2
+
3
+ # Tests for Morpheus::Cli::AffinityGroupsCommand
4
+ #
5
+ # Scope note: these cover only client-side behaviour -- help output and argument
6
+ # validation -- both of which resolve before any API call is made.
7
+ #
8
+ # Tests that would exercise list/get/add/update/remove against the appliance are
9
+ # deliberately omitted until the /api/affinity-groups request and response
10
+ # contract is confirmed against the MORPH-14136 / MORPH-16074 server work.
11
+ # See test/cli/affinity_helper_test.rb for the offline coverage of the shared
12
+ # affinity enums and formatters.
13
+ class MorpheusTest::AffinityGroupsTest < MorpheusTest::TestCase
14
+
15
+ def test_affinity_groups_help
16
+ assert_execute %(affinity-groups --help)
17
+ assert_execute %(affinity-groups list --help)
18
+ assert_execute %(affinity-groups get --help)
19
+ assert_execute %(affinity-groups add --help)
20
+ assert_execute %(affinity-groups update --help)
21
+ assert_execute %(affinity-groups remove --help)
22
+ end
23
+
24
+ ## Argument validation (fails during option parsing, before any request)
25
+
26
+ def test_affinity_groups_unknown_subcommand
27
+ assert_error %(affinity-groups nonsense-subcommand)
28
+ end
29
+
30
+ def test_affinity_groups_get_requires_an_id
31
+ assert_error %(affinity-groups get)
32
+ end
33
+
34
+ def test_affinity_groups_update_requires_an_id
35
+ assert_error %(affinity-groups update)
36
+ end
37
+
38
+ def test_affinity_groups_remove_requires_an_id
39
+ assert_error %(affinity-groups remove -y)
40
+ end
41
+
42
+ def test_affinity_groups_list_rejects_unknown_option
43
+ assert_error %(affinity-groups list --not-a-real-option)
44
+ end
45
+
46
+ # There is no unscoped list/create route -- morpheus-ui ApiV1UrlMappings
47
+ # exposes only /api/affinity-groups/{id} at the top level, so list and add
48
+ # must refuse to run without --cloud or --cluster rather than 404.
49
+ def test_affinity_groups_list_requires_a_scope
50
+ assert_error %(affinity-groups list)
51
+ end
52
+
53
+ def test_affinity_groups_add_requires_a_scope
54
+ assert_error %(affinity-groups add --name cli-test --dry-run)
55
+ end
56
+
57
+ end
@@ -0,0 +1,141 @@
1
+ require 'test/unit'
2
+ require 'stringio'
3
+ require 'morpheus'
4
+
5
+ # Offline exercise of the MORPH-17279 --cluster scope for
6
+ # AffinityGroupsCommand#violations. Unlike list/add, the violations endpoint
7
+ # (AffinityGroupsController#violations) only accepts clusterId or cloudId -- it
8
+ # has no poolId path and no vSphere-pool-scoped violation data. So a managed
9
+ # cluster name resolves to clusterId, while a cloud-owned vSphere cluster name
10
+ # must produce a clear error instead of silently exiting 1 or broadening scope.
11
+ class AffinityGroupsViolationsScopeTest < Test::Unit::TestCase
12
+ class FakeAffinityGroupsInterface
13
+ attr_reader :calls
14
+
15
+ def initialize
16
+ @calls = []
17
+ @dry = false
18
+ end
19
+
20
+ def setopts(_options)
21
+ self
22
+ end
23
+
24
+ def dry
25
+ @dry = true
26
+ self
27
+ end
28
+
29
+ def violations(params = {})
30
+ @calls << [:violations, params]
31
+ return {'violations' => []} unless @dry
32
+
33
+ {method: :get, url: "/api/affinity-groups/violations", params: params}
34
+ end
35
+ end
36
+
37
+ class FakeCloudsInterface
38
+ def initialize(cloud)
39
+ @cloud = cloud
40
+ end
41
+
42
+ def setopts(_options); self; end
43
+ def dry; self; end
44
+ def get(id, params = {}); {'zone' => @cloud}; end
45
+ def list(params = {}); {'zones' => [@cloud]}; end
46
+ end
47
+
48
+ class FakeResourcePoolsInterface
49
+ def initialize(pools)
50
+ @pools = pools
51
+ end
52
+
53
+ def get(cloud_id, id, params = {})
54
+ {'resourcePool' => @pools.find {|p| p['id'].to_i == id.to_i }}
55
+ end
56
+
57
+ def list(cloud_id, params = {})
58
+ {'resourcePools' => @pools}
59
+ end
60
+
61
+ def list_without_cloud(params = {})
62
+ {'resourcePools' => @pools}
63
+ end
64
+ end
65
+
66
+ class FakeClustersInterface
67
+ def initialize(clusters = [])
68
+ @clusters = clusters
69
+ end
70
+
71
+ def list(params = {})
72
+ {'clusters' => @clusters.select {|cluster| cluster['name'] == params[:name]}}
73
+ end
74
+ end
75
+
76
+ def setup
77
+ @cloud = {'id' => 59, 'name' => 'vCenter Cloud'}
78
+ @pool = {'id' => 501, 'name' => 'QA', 'type' => 'Cluster', 'zone' => @cloud}
79
+ end
80
+
81
+ def test_managed_cluster_name_scopes_violations_by_cluster_id
82
+ command, affinity = build_command([{'id' => 8, 'name' => 'QA'}])
83
+
84
+ output = capture_stdout { command.violations(['--cluster', 'QA', '--dry-run']) }
85
+
86
+ assert_match(%r{/api/affinity-groups/violations}, output)
87
+ assert_match(/clusterId=8/, output)
88
+ assert_equal [[:violations, {'clusterId' => 8}]], affinity.calls
89
+ end
90
+
91
+ def test_vsphere_cluster_name_reports_clear_error_without_calling_violations
92
+ command, affinity = build_command([])
93
+
94
+ result = nil
95
+ err = capture_stderr do
96
+ capture_stdout { result = command.violations(['--cluster', 'QA', '--dry-run']) }
97
+ end
98
+
99
+ assert_equal 1, (result.is_a?(Array) ? result[0] : result)
100
+ assert_match(/only available for Morpheus managed clusters/i, err)
101
+ assert_match(/vCenter Cloud/, err)
102
+ assert_empty affinity.calls
103
+ end
104
+
105
+ private
106
+
107
+ def build_command(managed_clusters = [])
108
+ affinity = FakeAffinityGroupsInterface.new
109
+ clouds = FakeCloudsInterface.new(@cloud)
110
+ pools = FakeResourcePoolsInterface.new([@pool])
111
+ clusters = FakeClustersInterface.new(managed_clusters)
112
+ command = Morpheus::Cli::AffinityGroupsCommand.new
113
+ command.define_singleton_method(:connect) do |_options|
114
+ @affinity_groups_interface = affinity
115
+ @clouds_interface = clouds
116
+ @clusters_interface = clusters
117
+ @cloud_resource_pools_interface = pools
118
+ end
119
+ [command, affinity]
120
+ end
121
+
122
+ def capture_stdout
123
+ terminal = Morpheus::Terminal.instance
124
+ original = terminal.stdout
125
+ buffer = StringIO.new
126
+ terminal.set_stdout(buffer)
127
+ yield
128
+ buffer.string
129
+ ensure
130
+ terminal.set_stdout(original)
131
+ end
132
+
133
+ def capture_stderr
134
+ original = $stderr
135
+ $stderr = StringIO.new
136
+ yield
137
+ $stderr.string
138
+ ensure
139
+ $stderr = original
140
+ end
141
+ end
@@ -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