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